Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions app/components/[category]/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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 (
<div>
Expand Down Expand Up @@ -191,6 +195,17 @@ export default async function ComponentPage({
</section>
) : null}

{propsDocs.length ? (
<section className="mt-12 border-t border-border pt-8">
<h2 className="text-sm font-semibold text-foreground">
API Reference
</h2>
<div className="mt-4">
<PropsTable docs={propsDocs} />
</div>
</section>
) : null}

{related.length ? (
<section className="mt-12 border-t border-border pt-8">
<h2 className="text-sm font-semibold text-foreground">
Expand All @@ -210,6 +225,8 @@ export default async function ComponentPage({
</div>
</section>
) : null}

<KeepInMind />
</div>
);
}
Expand All @@ -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 (
<section>
Expand Down Expand Up @@ -272,6 +290,16 @@ async function ExampleBlock({
</div>
</div>
) : null}
{propsDocs.length ? (
<div className="mt-5 min-w-0 border-t border-border pt-5">
<h3 className="text-sm font-semibold text-foreground">
API Reference
</h3>
<div className="mt-3">
<PropsTable docs={propsDocs} />
</div>
</div>
) : null}
</section>
);
}
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions components/app/docs/keep-in-mind.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Link from "next/link";

export function KeepInMind() {
return (
<section className="mt-12 border-t border-border pt-8">
<h2 className="text-sm font-semibold text-foreground">Keep in mind</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
Some components on this site are inspired by or recreated from existing
work across the web. I&apos;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,{" "}
<Link
href="https://x.com/saurra3h"
target="_blank"
rel="noreferrer noopener"
className="text-foreground underline-offset-2 hover:underline"
>
reach out
</Link>{" "}
and I&apos;ll fix that right away.
</p>
</section>
);
}
46 changes: 46 additions & 0 deletions components/app/docs/props-table.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-8">
{withProps.map((doc) => (
<div key={doc.displayName}>
{withProps.length > 1 ? (
<h3 className="mb-3 font-mono text-sm font-semibold text-foreground">
{doc.displayName}
</h3>
) : null}
<div className="divide-y divide-border rounded-xl border border-border">
{doc.props.map((prop) => (
<div
key={prop.name}
className="grid grid-cols-1 gap-2 px-4 py-3 sm:grid-cols-[140px_minmax(0,1fr)_100px] sm:gap-4"
>
<code className="h-fit w-fit rounded-md bg-foreground/5 px-2 py-0.5 font-mono text-[11px] text-foreground">
{prop.name}
{prop.required ? "" : "?"}
</code>
<div className="min-w-0">
<code className="w-fit rounded-md bg-foreground/5 px-2 py-0.5 font-mono text-[11px] text-muted-foreground">
{prop.type}
</code>
{prop.description ? (
<p className="mt-1.5 text-sm text-muted-foreground">
{prop.description}
</p>
) : null}
</div>
<code className="h-fit w-fit rounded-md bg-foreground/5 px-2 py-0.5 font-mono text-[11px] text-muted-foreground sm:justify-self-end">
{prop.defaultValue ?? "—"}
</code>
</div>
))}
</div>
</div>
))}
</div>
);
}
100 changes: 100 additions & 0 deletions lib/props-extractor.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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<HTMLMotionProps<"button">, "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<string, ComponentPropsDoc[]>();

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;
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Loading