Files
netto/scripts/normalize-component-docs.mjs
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

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