Files

339 lines
12 KiB
JavaScript
Raw Permalink Normal View History

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, "\\$&")
}