b3a7a2a8f5
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.
339 lines
12 KiB
JavaScript
339 lines
12 KiB
JavaScript
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, "\\$&")
|
|
}
|