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`] for (const part of parts.filter((part) => part !== "Root").slice(0, 5)) { lines.push(` <${component}.${part} />`) } lines.push(``) 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` | `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, "\\$&") }