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.
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import { readdir, readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
const uiComponentsPath = path.join("src", "components", "ui")
|
||||
const componentEntries = await readdir(uiComponentsPath, { withFileTypes: true })
|
||||
const components = componentEntries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
|
||||
const failures = []
|
||||
|
||||
for (const component of components) {
|
||||
const indexPath = path.join(uiComponentsPath, component, "index.ts")
|
||||
try {
|
||||
const source = await readFile(indexPath, "utf8")
|
||||
if (!source.includes("export default")) {
|
||||
failures.push(`${indexPath}: missing default export`)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") {
|
||||
throw error
|
||||
}
|
||||
failures.push(`${indexPath}: missing file`)
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(failures.join("\n"))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`Checked ${components.length} component exports.`)
|
||||
@@ -0,0 +1,166 @@
|
||||
import { readdir, readFile, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import process from "node:process"
|
||||
|
||||
const root = process.cwd()
|
||||
const sourceDir = path.join(root, "src")
|
||||
const componentsDir = path.join(sourceDir, "components", "ui")
|
||||
const libDir = path.join(sourceDir, "lib")
|
||||
const outputPath = path.join(root, "manifest.json")
|
||||
|
||||
const packageJson = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"))
|
||||
const components = await listDirectories(componentsDir)
|
||||
const utils = await listFiles(libDir)
|
||||
|
||||
const items = {}
|
||||
|
||||
for (const file of utils) {
|
||||
const name = path.basename(file, path.extname(file))
|
||||
const source = await readFile(path.join(root, file), "utf8")
|
||||
const target = stripSourcePrefix(file)
|
||||
items[`utils/${name}`] = {
|
||||
name: `utils/${name}`,
|
||||
type: "util",
|
||||
description: `Utility source for ${name}.`,
|
||||
dependencies: collectPackageDependencies(source),
|
||||
files: [
|
||||
{
|
||||
path: file,
|
||||
target,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
for (const component of components) {
|
||||
const componentFiles = await listFiles(path.join("src", "components", "ui", component))
|
||||
const sources = await Promise.all(
|
||||
componentFiles.map(async (file) => readFile(path.join(root, file), "utf8")),
|
||||
)
|
||||
const source = sources.join("\n")
|
||||
|
||||
items[component] = {
|
||||
name: component,
|
||||
type: "component",
|
||||
description: `${toTitle(component)} component wrapper for Base UI and Tailwind CSS.`,
|
||||
dependencies: collectPackageDependencies(source),
|
||||
localDependencies: collectLocalDependencies(source, component, components, utils),
|
||||
files: componentFiles.map((file) => ({
|
||||
path: file,
|
||||
target: stripSourcePrefix(file),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
name: "base-ui-tailwind",
|
||||
version: packageJson.version ?? "0.0.0",
|
||||
generatedAt: new Date().toISOString(),
|
||||
command: "base-ui-tailwind",
|
||||
configFile: "base-ui-tailwind.json",
|
||||
dependencies: {
|
||||
"@base-ui/react": packageJson.dependencies?.["@base-ui/react"],
|
||||
"clsx": packageJson.dependencies?.clsx,
|
||||
"lucide-react": packageJson.dependencies?.["lucide-react"],
|
||||
"tailwind-merge": packageJson.dependencies?.["tailwind-merge"],
|
||||
},
|
||||
items,
|
||||
}
|
||||
|
||||
await writeFile(outputPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
console.log(`Generated manifest.json with ${Object.keys(items).length} items.`)
|
||||
|
||||
async function listDirectories(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true })
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
async function listFiles(relativeOrAbsoluteDirectory) {
|
||||
const absoluteDirectory = path.isAbsolute(relativeOrAbsoluteDirectory)
|
||||
? relativeOrAbsoluteDirectory
|
||||
: path.join(root, relativeOrAbsoluteDirectory)
|
||||
const relativeDirectory = path.relative(root, absoluteDirectory)
|
||||
const entries = await readdir(absoluteDirectory, { withFileTypes: true })
|
||||
const files = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const relativePath = path.join(relativeDirectory, entry.name)
|
||||
const absolutePath = path.join(absoluteDirectory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await listFiles(absolutePath)))
|
||||
} else if (entry.isFile()) {
|
||||
files.push(relativePath.split(path.sep).join("/"))
|
||||
}
|
||||
}
|
||||
|
||||
return files.sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
function collectPackageDependencies(source) {
|
||||
const dependencies = new Set()
|
||||
const importPattern = /from\s+["']([^"']+)["']|import\s+["']([^"']+)["']/g
|
||||
|
||||
for (const match of source.matchAll(importPattern)) {
|
||||
const specifier = match[1] ?? match[2]
|
||||
if (!specifier || specifier.startsWith(".") || specifier.startsWith("@/")) {
|
||||
continue
|
||||
}
|
||||
|
||||
dependencies.add(getPackageName(specifier))
|
||||
}
|
||||
|
||||
return [...dependencies].sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
function collectLocalDependencies(source, component, components, utils) {
|
||||
const dependencies = new Set()
|
||||
const localPattern = /from\s+["']@\/([^"']+)["']|import\s+["']@\/([^"']+)["']/g
|
||||
|
||||
for (const match of source.matchAll(localPattern)) {
|
||||
const specifier = match[1] ?? match[2]
|
||||
if (!specifier) {
|
||||
continue
|
||||
}
|
||||
|
||||
const componentMatch = specifier.match(/^components\/ui\/([^/]+)/)
|
||||
if (componentMatch && componentMatch[1] !== component && components.includes(componentMatch[1])) {
|
||||
dependencies.add(componentMatch[1])
|
||||
continue
|
||||
}
|
||||
|
||||
const utilMatch = specifier.match(/^lib\/([^/]+)/)
|
||||
if (utilMatch) {
|
||||
const utilPath = utils.find(
|
||||
(file) => file === `src/lib/${utilMatch[1]}.ts` || file === `src/lib/${utilMatch[1]}.tsx`,
|
||||
)
|
||||
if (utilPath) {
|
||||
dependencies.add(`utils/${path.basename(utilPath, path.extname(utilPath))}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...dependencies].sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
function stripSourcePrefix(file) {
|
||||
return file.startsWith("src/") ? file.slice("src/".length) : file
|
||||
}
|
||||
|
||||
function getPackageName(specifier) {
|
||||
if (specifier.startsWith("@")) {
|
||||
const [scope, name] = specifier.split("/")
|
||||
return `${scope}/${name}`
|
||||
}
|
||||
|
||||
return specifier.split("/")[0]
|
||||
}
|
||||
|
||||
function toTitle(value) {
|
||||
return value
|
||||
.split("-")
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ")
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
const root = process.cwd()
|
||||
const contentDir = path.join(root, "site/src/content/components")
|
||||
const uiDir = path.join(root, "src/components/ui")
|
||||
const examplesDir = path.join(root, "site/src/examples/components")
|
||||
const finished = new Set(["menu", "context-menu", "navigation-menu"])
|
||||
|
||||
const names = {
|
||||
"alert-dialog": "AlertDialog",
|
||||
"checkbox-group": "CheckboxGroup",
|
||||
"context-menu": "ContextMenu",
|
||||
"navigation-menu": "NavigationMenu",
|
||||
"number-field": "NumberField",
|
||||
"otp-field": "OtpField",
|
||||
"preview-card": "PreviewCard",
|
||||
"scroll-area": "ScrollArea",
|
||||
"toggle-group": "ToggleGroup",
|
||||
}
|
||||
|
||||
const officialSlugs = {
|
||||
"otp-field": "otp-field",
|
||||
}
|
||||
|
||||
for (const file of fs.readdirSync(contentDir).sort()) {
|
||||
if (!file.endsWith(".mdx")) continue
|
||||
|
||||
const slug = file.replace(/\.mdx$/, "")
|
||||
if (finished.has(slug)) continue
|
||||
|
||||
const filePath = path.join(contentDir, file)
|
||||
const current = fs.readFileSync(filePath, "utf8")
|
||||
const title = current.match(/^# (.+)$/m)?.[1] ?? titleCase(slug)
|
||||
const component = names[slug] ?? title.replace(/[^A-Za-z0-9]/g, "")
|
||||
const parts = readParts(slug)
|
||||
const exampleSrc = `components/${slug}/${slug}.tsx`
|
||||
const hasTopExample = fs.existsSync(path.join(examplesDir, slug, `${slug}.tsx`))
|
||||
const intro = readIntro(current, title)
|
||||
const baseStyle = readSectionCode(current, "Base UI Style") ?? makeBaseStyle(slug, component, parts)
|
||||
const shadcnStyle =
|
||||
readSectionCode(current, "Shadcn Style") ??
|
||||
readSectionCode(current, "Named Import Style") ??
|
||||
makeNamedStyle(slug, component, parts)
|
||||
const examples = readExamples(current, title, slug, exampleSrc, hasTopExample)
|
||||
|
||||
const next = [
|
||||
`# ${title}`,
|
||||
"",
|
||||
intro || `${title} wraps Base UI's ${title.toLowerCase()} primitive with the Tailwind classes from the official Base UI example.`,
|
||||
"",
|
||||
hasTopExample ? `::example{src="${exampleSrc}"}` : "",
|
||||
"",
|
||||
"## Anatomy",
|
||||
"",
|
||||
"### Base UI Style",
|
||||
"",
|
||||
fenced(baseStyle),
|
||||
"",
|
||||
"### Shadcn Style",
|
||||
"",
|
||||
fenced(shadcnStyle),
|
||||
"",
|
||||
"## Composition",
|
||||
"",
|
||||
`Use the following composition to build \`${component}\`:`,
|
||||
"",
|
||||
"```txt",
|
||||
makeComposition(component, parts).join("\n"),
|
||||
"```",
|
||||
"",
|
||||
"## Examples",
|
||||
"",
|
||||
examples.trim(),
|
||||
"",
|
||||
makeApi(slug, title, component, parts),
|
||||
"",
|
||||
]
|
||||
.filter((line, index, array) => !(line === "" && array[index - 1] === ""))
|
||||
.join("\n")
|
||||
|
||||
fs.writeFileSync(filePath, next)
|
||||
}
|
||||
|
||||
function readParts(slug) {
|
||||
const indexPath = path.join(uiDir, slug, "index.ts")
|
||||
if (!fs.existsSync(indexPath)) return ["Root"]
|
||||
|
||||
const source = fs.readFileSync(indexPath, "utf8")
|
||||
const objectMatch = source.match(/const\s+\w+\s*=\s*\{([\s\S]*?)\n\}/)
|
||||
if (!objectMatch) return ["Root"]
|
||||
|
||||
const parts = []
|
||||
for (const line of objectMatch[1].split("\n")) {
|
||||
const match = line.match(/^\s*([A-Za-z][A-Za-z0-9]*):/)
|
||||
if (!match) continue
|
||||
const part = match[1]
|
||||
if (part[0] === part[0].toLowerCase()) continue
|
||||
if (!parts.includes(part)) parts.push(part)
|
||||
}
|
||||
return parts.length ? parts : ["Root"]
|
||||
}
|
||||
|
||||
function readIntro(source, title) {
|
||||
const start = source.indexOf(`# ${title}`)
|
||||
if (start === -1) return ""
|
||||
let rest = source.slice(start + title.length + 3)
|
||||
const stops = ["\n<ComponentPreview", "\nexport ", "\n## ", "\n::example"]
|
||||
let end = rest.length
|
||||
for (const stop of stops) {
|
||||
const index = rest.indexOf(stop)
|
||||
if (index !== -1 && index < end) end = index
|
||||
}
|
||||
rest = rest.slice(0, end)
|
||||
return rest
|
||||
.replace(/^import .+$/gm, "")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function readSectionCode(source, heading) {
|
||||
const start = source.indexOf(`## ${heading}`)
|
||||
if (start === -1) return null
|
||||
const fenceStart = source.indexOf("```", start)
|
||||
if (fenceStart === -1) return null
|
||||
const lineEnd = source.indexOf("\n", fenceStart)
|
||||
const fenceEnd = source.indexOf("\n```", lineEnd)
|
||||
if (fenceEnd === -1) return null
|
||||
return source.slice(lineEnd + 1, fenceEnd).trim()
|
||||
}
|
||||
|
||||
function readExamples(source, title, slug, exampleSrc, hasTopExample) {
|
||||
const start = source.indexOf("## Examples")
|
||||
if (start === -1) return ""
|
||||
const nextHeading = source.slice(start + 1).search(/\n## /)
|
||||
const end = nextHeading === -1 ? source.length : start + 1 + nextHeading
|
||||
|
||||
let content = source.slice(start + "## Examples".length, end).trim()
|
||||
if (hasTopExample) {
|
||||
const escapedTitle = escapeRegExp(title)
|
||||
const escapedSrc = escapeRegExp(exampleSrc)
|
||||
const duplicate = new RegExp(`^### ${escapedTitle}\\n\\n::example\\{src="${escapedSrc}"\\}\\n*`, "m")
|
||||
content = content.replace(duplicate, "").trim()
|
||||
}
|
||||
return content || "No additional examples yet."
|
||||
}
|
||||
|
||||
function makeBaseStyle(slug, component, parts) {
|
||||
const lines = [`import ${component} from "@/components/ui/${slug}"`, "", `<${component}.Root>`]
|
||||
for (const part of parts.filter((part) => part !== "Root").slice(0, 5)) {
|
||||
lines.push(` <${component}.${part} />`)
|
||||
}
|
||||
lines.push(`</${component}.Root>`)
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
function makeNamedStyle(slug, component, parts) {
|
||||
const named = parts.map((part) => (part === "Root" ? component : `${component}${part}`))
|
||||
return [
|
||||
"import {",
|
||||
...named.slice(0, 8).map((name) => ` ${name},`),
|
||||
`} from "@/components/ui/${slug}"`,
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
function makeComposition(component, parts) {
|
||||
const has = (part) => parts.includes(part)
|
||||
const portalChildren = []
|
||||
if (has("Backdrop")) portalChildren.push(`${component}Backdrop`)
|
||||
if (has("Positioner")) {
|
||||
const popupChildren = []
|
||||
for (const part of ["Arrow", "Viewport", "List", "Collection", "Group", "Item", "Empty"]) {
|
||||
if (has(part)) popupChildren.push(`${component}${part}`)
|
||||
}
|
||||
portalChildren.push({
|
||||
label: `${component}Positioner`,
|
||||
children: has("Popup") ? [{ label: `${component}Popup`, children: popupChildren }] : popupChildren,
|
||||
})
|
||||
} else if (has("Popup")) {
|
||||
portalChildren.push(`${component}Popup`)
|
||||
}
|
||||
|
||||
const children = []
|
||||
const leading = ["Trigger", "Label", "Input", "Control", "Track", "Thumb", "Image", "Fallback", "Group"]
|
||||
for (const part of leading) {
|
||||
if (has(part)) children.push(`${component}${part}`)
|
||||
}
|
||||
if (has("Portal")) {
|
||||
children.push({ label: `${component}Portal`, children: portalChildren })
|
||||
}
|
||||
for (const part of parts) {
|
||||
if (part === "Root" || leading.includes(part) || part === "Portal" || part === "Backdrop" || part === "Positioner" || part === "Popup") {
|
||||
continue
|
||||
}
|
||||
const label = part === "Separator" ? `${component}.Separator` : `${component}${part}`
|
||||
if (!treeHas(children, label)) children.push(label)
|
||||
}
|
||||
return renderTree({ label: component, children })
|
||||
}
|
||||
|
||||
function treeHas(children, label) {
|
||||
for (const child of children) {
|
||||
if (typeof child === "string" && child === label) return true
|
||||
if (typeof child === "object") {
|
||||
if (child.label === label) return true
|
||||
if (treeHas(child.children ?? [], label)) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function renderTree(node, prefix = "") {
|
||||
const lines = [prefix ? `${prefix}${node.label}` : node.label]
|
||||
const children = node.children ?? []
|
||||
children.forEach((child, index) => {
|
||||
const last = index === children.length - 1
|
||||
const branch = last ? "└── " : "├── "
|
||||
const nextPrefix = prefix + (last ? " " : "│ ")
|
||||
if (typeof child === "string") {
|
||||
lines.push(`${prefix}${branch}${child}`)
|
||||
} else {
|
||||
lines.push(`${prefix}${branch}${child.label}`)
|
||||
lines.push(...renderChildren(child.children ?? [], nextPrefix))
|
||||
}
|
||||
})
|
||||
return lines
|
||||
}
|
||||
|
||||
function renderChildren(children, prefix) {
|
||||
const lines = []
|
||||
children.forEach((child, index) => {
|
||||
const last = index === children.length - 1
|
||||
const branch = last ? "└── " : "├── "
|
||||
const nextPrefix = prefix + (last ? " " : "│ ")
|
||||
if (typeof child === "string") {
|
||||
lines.push(`${prefix}${branch}${child}`)
|
||||
} else {
|
||||
lines.push(`${prefix}${branch}${child.label}`)
|
||||
lines.push(...renderChildren(child.children ?? [], nextPrefix))
|
||||
}
|
||||
})
|
||||
return lines
|
||||
}
|
||||
|
||||
function makeApi(slug, title, component, parts) {
|
||||
const official = `https://base-ui.com/react/components/${officialSlugs[slug] ?? slug}#api-reference`
|
||||
const sections = parts.map((part) => makeApiPart(component, part)).join("\n\n")
|
||||
return [
|
||||
"## API Reference",
|
||||
"",
|
||||
`Official API: [Base UI ${title} API](${official}).`,
|
||||
"",
|
||||
`This wrapper preserves the underlying Base UI ${title} API. Styled parts forward \`className\`, \`style\`, and \`render\` where Base UI supports them.`,
|
||||
"",
|
||||
sections,
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
function makeApiPart(component, part) {
|
||||
const heading = part === "Root" ? "Root" : part
|
||||
const label = part === "Root" ? component : `${component}.${part}`
|
||||
const rows = apiRows(part)
|
||||
return [
|
||||
`### ${heading}`,
|
||||
"",
|
||||
`\`${label}\` exposes the corresponding Base UI part with the wrapper's Tailwind styles.`,
|
||||
"",
|
||||
"| Prop | Type | Default | Description |",
|
||||
"| --- | --- | --- | --- |",
|
||||
...rows,
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
function apiRows(part) {
|
||||
if (part === "Root") {
|
||||
return [
|
||||
"| `defaultValue` / `defaultOpen` | `any` | - | Initial uncontrolled value or open state when supported. |",
|
||||
"| `value` / `open` | `any` | - | Controlled value or open state when supported. |",
|
||||
"| `onValueChange` / `onOpenChange` | `function` | - | Called when controlled state changes. |",
|
||||
"| `children` | `React.ReactNode` | - | Child parts rendered inside the root. |",
|
||||
]
|
||||
}
|
||||
if (part === "Portal") {
|
||||
return [
|
||||
"| `container` | `HTMLElement \\| RefObject<HTMLElement>` | `document.body` | Element that receives portal content. |",
|
||||
"| `keepMounted` | `boolean` | `false` | Keeps children mounted when closed. |",
|
||||
"| `children` | `React.ReactNode` | - | Floating content. |",
|
||||
]
|
||||
}
|
||||
if (part === "Positioner") {
|
||||
return [
|
||||
"| `side` | `Side` | - | Preferred side for floating content. |",
|
||||
"| `align` | `Align` | - | Alignment on the selected side. |",
|
||||
"| `sideOffset` | `number \\| OffsetFunction` | `0` | Distance from the anchor. |",
|
||||
"| `collisionAvoidance` | `CollisionAvoidance` | - | Controls flip and shift behavior. |",
|
||||
"| `className` | `string \\| (state) => string` | - | Classes for placement state. |",
|
||||
]
|
||||
}
|
||||
if (["Trigger", "Item", "LinkItem", "Link", "Button", "Close", "Action"].includes(part)) {
|
||||
return [
|
||||
"| `disabled` | `boolean` | `false` | Prevents user interaction when supported. |",
|
||||
"| `onClick` | `function` | - | Called when the element is pressed. |",
|
||||
"| `className` | `string \\| (state) => string` | - | Classes for interactive state. |",
|
||||
"| `render` | `ReactElement \\| (props, state) => ReactElement` | - | Replaces the rendered element. |",
|
||||
"| `children` | `React.ReactNode` | - | Visible content. |",
|
||||
]
|
||||
}
|
||||
if (["CheckboxItem", "RadioGroup", "RadioItem"].includes(part)) {
|
||||
return [
|
||||
"| `checked` / `value` | `any` | - | Controlled checked or selected state when supported. |",
|
||||
"| `defaultChecked` / `defaultValue` | `any` | - | Initial uncontrolled state when supported. |",
|
||||
"| `onCheckedChange` / `onValueChange` | `function` | - | Called when selection changes. |",
|
||||
"| `disabled` | `boolean` | `false` | Prevents user interaction. |",
|
||||
"| `className` | `string \\| (state) => string` | - | Classes for checked, highlighted, and disabled state. |",
|
||||
]
|
||||
}
|
||||
return [
|
||||
"| `className` | `string \\| (state) => string` | - | Classes for the part state. |",
|
||||
"| `style` | `React.CSSProperties \\| (state) => React.CSSProperties` | - | Inline style or state-based style. |",
|
||||
"| `render` | `ReactElement \\| (props, state) => ReactElement` | - | Replaces the rendered element when supported. |",
|
||||
"| `children` | `React.ReactNode` | - | Child content when the part accepts children. |",
|
||||
]
|
||||
}
|
||||
|
||||
function fenced(value) {
|
||||
return ["```tsx", value.trim(), "```"].join("\n")
|
||||
}
|
||||
|
||||
function titleCase(slug) {
|
||||
return slug
|
||||
.split("-")
|
||||
.map((word) => word[0].toUpperCase() + word.slice(1))
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
}
|
||||
Reference in New Issue
Block a user