diff --git a/app/components/[category]/[slug]/page.tsx b/app/components/[category]/[slug]/page.tsx index 81c279a..1c439e4 100644 --- a/app/components/[category]/[slug]/page.tsx +++ b/app/components/[category]/[slug]/page.tsx @@ -10,6 +10,8 @@ import { } from "@/lib/registry"; import { CodeBlock } from "@/components/app/docs/code-block"; import { InstallBlock } from "@/components/app/docs/install-block"; +import { PropsTable } from "@/components/app/docs/props-table"; +import { KeepInMind } from "@/components/app/docs/keep-in-mind"; import { Tabs, TabsContent, @@ -22,6 +24,7 @@ import { JsonLd } from "@/components/app/analytics/json-ld"; import { getPreview, previews } from "@/components/previews"; import { pageUrlFor, withSignature } from "@/lib/signature"; import { readSourceFile } from "@/lib/source-files"; +import { getComponentProps } from "@/lib/props-extractor"; import { breadcrumbJsonLd, componentJsonLd, @@ -131,6 +134,7 @@ export default async function ComponentPage({ const hasVariantInstallCommands = comp.examples?.some((example) => example.installSlug) ?? false; const related = relatedComponents(cat.slug, comp.slug, 3); + const propsDocs = comp.examples?.length ? [] : getComponentProps(comp.file); return (
@@ -191,6 +195,17 @@ export default async function ComponentPage({ ) : null} + {propsDocs.length ? ( +
+

+ API Reference +

+
+ +
+
+ ) : null} + {related.length ? (

@@ -210,6 +225,8 @@ export default async function ComponentPage({

) : null} + + ); } @@ -227,6 +244,7 @@ async function ExampleBlock({ const source = await loadSource(example.file); const usage = await loadSource(example.previewFile); const installSlug = example.installSlug ?? null; + const propsDocs = getComponentProps(example.file); return (
@@ -272,6 +290,16 @@ async function ExampleBlock({ ) : null} + {propsDocs.length ? ( +
+

+ API Reference +

+
+ +
+
+ ) : null}
); } diff --git a/bun.lock b/bun.lock index 009fd55..f90377c 100644 --- a/bun.lock +++ b/bun.lock @@ -36,6 +36,7 @@ "@types/react-dom": "^19.0.3", "jest-axe": "^10.0.0", "postcss": "^8.5.1", + "react-docgen-typescript": "^2.4.0", "tailwindcss": "^4.0.0", "typescript": "^5.7.3", }, @@ -432,6 +433,8 @@ "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], + "react-docgen-typescript": ["react-docgen-typescript@2.4.0", "", { "peerDependencies": { "typescript": ">= 4.3.x" } }, "sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg=="], + "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], diff --git a/components/app/docs/keep-in-mind.tsx b/components/app/docs/keep-in-mind.tsx new file mode 100644 index 0000000..e8b417a --- /dev/null +++ b/components/app/docs/keep-in-mind.tsx @@ -0,0 +1,24 @@ +import Link from "next/link"; + +export function KeepInMind() { + return ( +
+

Keep in mind

+

+ Some components on this site are inspired by or recreated from existing + work across the web. I'm not here to take credit; just to learn, + experiment, and sometimes push things a bit further. If something looks + familiar and I forgot to mention you,{" "} + + reach out + {" "} + and I'll fix that right away. +

+
+ ); +} diff --git a/components/app/docs/props-table.tsx b/components/app/docs/props-table.tsx new file mode 100644 index 0000000..4cf3832 --- /dev/null +++ b/components/app/docs/props-table.tsx @@ -0,0 +1,46 @@ +import type { ComponentPropsDoc } from "@/lib/props-extractor"; + +export function PropsTable({ docs }: { docs: ComponentPropsDoc[] }) { + const withProps = docs.filter((doc) => doc.props.length > 0); + if (!withProps.length) return null; + + return ( +
+ {withProps.map((doc) => ( +
+ {withProps.length > 1 ? ( +

+ {doc.displayName} +

+ ) : null} +
+ {doc.props.map((prop) => ( +
+ + {prop.name} + {prop.required ? "" : "?"} + +
+ + {prop.type} + + {prop.description ? ( +

+ {prop.description} +

+ ) : null} +
+ + {prop.defaultValue ?? "—"} + +
+ ))} +
+
+ ))} +
+ ); +} diff --git a/lib/props-extractor.ts b/lib/props-extractor.ts new file mode 100644 index 0000000..eea3d96 --- /dev/null +++ b/lib/props-extractor.ts @@ -0,0 +1,100 @@ +import path from "node:path"; +import ts from "typescript"; +import * as docgen from "react-docgen-typescript"; +import { registry } from "@/lib/registry"; + +const PROJECT_ROOT = process.cwd(); + +export type PropDoc = { + name: string; + type: string; + required: boolean; + defaultValue: string | null; + description: string; +}; + +export type ComponentPropsDoc = { + displayName: string; + props: PropDoc[]; +}; + +function allRegisteredFiles(): string[] { + const files = new Set(); + for (const category of registry) { + for (const comp of category.components) { + files.add(path.join(PROJECT_ROOT, comp.file)); + for (const example of comp.examples ?? []) { + files.add(path.join(PROJECT_ROOT, example.file)); + } + } + } + return Array.from(files); +} + +let program: ts.Program | null = null; + +function getProgram() { + if (!program) { + const configPath = path.join(PROJECT_ROOT, "tsconfig.json"); + const configFile = ts.readConfigFile(configPath, ts.sys.readFile); + const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, PROJECT_ROOT); + program = ts.createProgram(allRegisteredFiles(), parsed.options); + } + return program; +} + +const parser = docgen.withDefaultConfig({ + shouldExtractLiteralValuesFromEnum: true, + shouldRemoveUndefinedFromOptional: true, + savePropValueAsString: true, +}); + +function formatType(prop: docgen.PropItem): string { + if (prop.type?.name === "enum" && Array.isArray(prop.type.value)) { + return prop.type.value.map((v: { value: string }) => v.value).join(" | "); + } + return prop.type?.name ?? "unknown"; +} + +function isFromNodeModules(prop: docgen.PropItem): boolean { + return prop.parent?.fileName.includes("node_modules") ?? false; +} + +// Interfaces like `ButtonProps extends Omit, "children">` +// merge in framer-motion/DOM attributes whose origin the type checker can't always +// trace back to node_modules (parent comes back undefined instead). So: if *any* +// prop on this component resolves to node_modules, treat every untraceable prop as +// inherited noise too and drop it, keeping only props traceable to this repo. If +// none do (e.g. a component with a plain inline prop-object type), nothing was +// spread in from a library type, so keep everything as-is. +function ownProps(props: docgen.PropItem[]): docgen.PropItem[] { + const hasLibraryProps = props.some(isFromNodeModules); + if (!hasLibraryProps) return props; + // Every beUI component accepts `className` (merged via `cn()`), even when it + // arrives through a native/motion passthrough rather than a hand-declared field. + return props.filter( + (prop) => prop.name === "className" || (prop.parent && !isFromNodeModules(prop)), + ); +} + +const cache = new Map(); + +export function getComponentProps(relFile: string): ComponentPropsDoc[] { + const absPath = path.join(PROJECT_ROOT, relFile); + const cached = cache.get(absPath); + if (cached) return cached; + + const docs = parser.parseWithProgramProvider([absPath], getProgram); + const result: ComponentPropsDoc[] = docs.map((doc) => ({ + displayName: doc.displayName, + props: ownProps(Object.values(doc.props ?? {})).map((prop) => ({ + name: prop.name, + type: formatType(prop), + required: prop.required, + defaultValue: (prop.defaultValue?.value as string | undefined) ?? null, + description: prop.description || "", + })), + })); + cache.set(absPath, result); + return result; +} diff --git a/package.json b/package.json index c6851ea..0e9a216 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@types/react-dom": "^19.0.3", "jest-axe": "^10.0.0", "postcss": "^8.5.1", + "react-docgen-typescript": "^2.4.0", "tailwindcss": "^4.0.0", "typescript": "^5.7.3" }