Files
Maofeng b3a7a2a8f5 feat: add Base UI Tailwind component library
Add composable wrappers for 37 Base UI component families.

Expose primitive and convenience APIs with shared styling utilities.

Add the registry manifest, installer CLI, generation and export checks.

Include a smoke example covering primitive and shadcn-style imports.
2026-07-25 06:04:19 +08:00

309 lines
7.8 KiB
JavaScript
Executable File

#!/usr/bin/env node
import { accessSync } from "node:fs"
import { access, copyFile, mkdir, readFile, writeFile } from "node:fs/promises"
import path from "node:path"
import process from "node:process"
import { fileURLToPath } from "node:url"
const command = process.argv[2]
const args = process.argv.slice(3)
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
const bundledManifestPath = path.join(packageRoot, "manifest.json")
const configFileName = "base-ui-tailwind.json"
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})
async function main() {
if (!command || command === "-h" || command === "--help") {
printHelp()
return
}
if (command === "init") {
await initProject(parseOptions(args))
return
}
if (command === "add") {
await addItems(parseOptions(args))
return
}
throw new Error(`Unknown command: ${command}`)
}
async function initProject(options) {
const cwd = path.resolve(options.cwd ?? process.cwd())
const configPath = path.join(cwd, configFileName)
const localManifestPath = path.join(cwd, "manifest.json")
const config = {
manifest: options.manifest ?? ((await exists(localManifestPath)) ? "manifest.json" : bundledManifestPath),
aliases: {
ui: "components/ui",
lib: "lib",
},
}
if (!options.force && (await exists(configPath))) {
console.log(`${configFileName} already exists. Use --force to overwrite it.`)
return
}
await mkdir(path.join(cwd, config.aliases.ui), { recursive: true })
await mkdir(path.join(cwd, config.aliases.lib), { recursive: true })
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`)
console.log(`Created ${path.relative(process.cwd(), configPath) || configFileName}.`)
}
async function addItems(options) {
const names = options._.filter((item) => !item.startsWith("-"))
if (names.length === 0) {
throw new Error("Usage: base-ui-tailwind add <name...>")
}
const cwd = path.resolve(options.cwd ?? process.cwd())
const config = await loadConfig(cwd)
const manifestRef = options.manifest ?? config.manifest ?? bundledManifestPath
const manifest = await loadManifest(manifestRef, cwd)
const selected = resolveItems(manifest, names)
const files = collectFiles(manifest, selected)
const written = []
const skipped = []
const packageDependencies = collectPackageDependencies(manifest, selected)
for (const file of files) {
const targetPath = path.join(cwd, resolveTargetPath(file.target, config.aliases))
if (!options.overwrite && (await exists(targetPath))) {
skipped.push(path.relative(cwd, targetPath))
continue
}
if (!options.dryRun) {
await mkdir(path.dirname(targetPath), { recursive: true })
const source = await readSourceFile(file.path, manifestRef, cwd)
if (source.kind === "local") {
await copyFile(source.path, targetPath)
} else {
await writeFile(targetPath, source.contents)
}
}
written.push(path.relative(cwd, targetPath))
}
if (written.length > 0) {
console.log(`${options.dryRun ? "Would add" : "Added"}:`)
for (const file of written) {
console.log(` ${file}`)
}
}
if (skipped.length > 0) {
console.log("Skipped existing files:")
for (const file of skipped) {
console.log(` ${file}`)
}
console.log("Use --overwrite to replace existing files.")
}
if (packageDependencies.length > 0) {
console.log("Required packages:")
console.log(` ${packageDependencies.join(" ")}`)
}
}
function resolveItems(manifest, names) {
const resolved = new Set()
const queue = [...names]
while (queue.length > 0) {
const name = queue.shift()
const item = manifest.items?.[name]
if (!item) {
throw new Error(`Manifest item not found: ${name}`)
}
if (resolved.has(name)) {
continue
}
resolved.add(name)
for (const dependency of item.localDependencies ?? []) {
queue.push(dependency)
}
}
return [...resolved]
}
function collectFiles(manifest, names) {
const files = []
const seen = new Set()
for (const name of names) {
for (const file of manifest.items[name].files ?? []) {
const key = file.target
if (!seen.has(key)) {
seen.add(key)
files.push(file)
}
}
}
return files
}
function collectPackageDependencies(manifest, names) {
const dependencies = new Set()
for (const name of names) {
for (const dependency of manifest.items[name].dependencies ?? []) {
dependencies.add(dependency)
}
}
return [...dependencies].sort((a, b) => a.localeCompare(b))
}
async function loadConfig(cwd) {
const configPath = path.join(cwd, configFileName)
if (!(await exists(configPath))) {
return {
manifest: undefined,
aliases: {
ui: "components/ui",
lib: "lib",
},
}
}
const config = JSON.parse(await readFile(configPath, "utf8"))
return {
manifest: config.manifest,
aliases: {
ui: config.aliases?.ui ?? "components/ui",
lib: config.aliases?.lib ?? "lib",
},
}
}
async function loadManifest(manifestRef, cwd) {
const contents = isUrl(manifestRef)
? await fetchText(manifestRef)
: await readFile(resolveLocalPath(manifestRef, cwd), "utf8")
const manifest = JSON.parse(contents)
if (!manifest.items || typeof manifest.items !== "object") {
throw new Error("Invalid manifest: missing items.")
}
return manifest
}
async function readSourceFile(filePath, manifestRef, cwd) {
if (isUrl(manifestRef)) {
return {
kind: "remote",
contents: await fetchText(new URL(filePath, manifestRef).toString()),
}
}
return {
kind: "local",
path: path.join(path.dirname(resolveLocalPath(manifestRef, cwd)), filePath),
}
}
function resolveTargetPath(target, aliases) {
if (target.startsWith("src/components/ui/")) {
return path.join(aliases.ui, target.slice("src/components/ui/".length))
}
if (target.startsWith("src/lib/")) {
return path.join(aliases.lib, target.slice("src/lib/".length))
}
if (target.startsWith("components/ui/")) {
return path.join(aliases.ui, target.slice("components/ui/".length))
}
if (target.startsWith("lib/")) {
return path.join(aliases.lib, target.slice("lib/".length))
}
return target
}
function resolveLocalPath(value, cwd) {
if (path.isAbsolute(value)) {
return value
}
const projectPath = path.resolve(cwd, value)
return value === "manifest.json" && !existsSync(projectPath) ? bundledManifestPath : projectPath
}
function parseOptions(values) {
const options = { _: [] }
for (let index = 0; index < values.length; index += 1) {
const value = values[index]
if (value === "--cwd") {
options.cwd = values[++index]
} else if (value === "--manifest") {
options.manifest = values[++index]
} else if (value === "--force") {
options.force = true
} else if (value === "--overwrite") {
options.overwrite = true
} else if (value === "--dry-run") {
options.dryRun = true
} else {
options._.push(value)
}
}
return options
}
async function fetchText(url) {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`)
}
return response.text()
}
function existsSync(filePath) {
try {
accessSync(filePath)
return true
} catch {
return false
}
}
async function exists(filePath) {
try {
await access(filePath)
return true
} catch {
return false
}
}
function isUrl(value) {
return /^https?:\/\//.test(value)
}
function printHelp() {
console.log(`base-ui-tailwind
Usage:
base-ui-tailwind init [--manifest <path-or-url>] [--cwd <dir>] [--force]
base-ui-tailwind add <name...> [--manifest <path-or-url>] [--cwd <dir>] [--overwrite] [--dry-run]
`)
}