From 837c800836463953cd59af53784a23fb9feb9e23 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 29 Oct 2026 17:13:35 +0530 Subject: [PATCH 1/3] feat: [docs] extend the props table transform for generated tables --- apps/www/src/lib/type-table.ts | 143 +++++++++++++++++++++++++++++++-- apps/www/tsconfig.props.json | 7 +- 2 files changed, 142 insertions(+), 8 deletions(-) diff --git a/apps/www/src/lib/type-table.ts b/apps/www/src/lib/type-table.ts index eac6b5acc..f98ab7cd4 100644 --- a/apps/www/src/lib/type-table.ts +++ b/apps/www/src/lib/type-table.ts @@ -1,12 +1,60 @@ import * as path from 'node:path'; -import { createGenerator, type GenerateOptions } from 'fumadocs-typescript'; +import { + createGenerator, + type DocEntry, + type GenerateOptions, + type RawTag +} from 'fumadocs-typescript'; type Transform = NonNullable; -export const generator = createGenerator({ +// The declaration nodes come from the TypeScript 7 in fumadocs-typescript. +// apps/www has TypeScript 5, so the transform reads them through these shapes. +interface SourceNode { + parent?: SourceNode; + name?: { text?: string }; + properties?: SourceNode[]; + initializer?: SourceNode; + type?: SourceNode; + jsDoc?: { + comment?: unknown; + tags?: { tagName: { text: string }; comment?: unknown }[]; + }[]; + getText(): string; + getSourceFile(): { fileName: string }; +} + +interface Checker { + getTypeAtLocation(node: SourceNode): unknown; + getPropertiesOfType(type: unknown): readonly { name: string }[]; +} + +// The transform cannot remove an entry, so it marks the entries to hide and +// the generator drops them. +const hidden = new WeakSet(); + +const base = createGenerator({ tsconfigPath: path.resolve('tsconfig.props.json') }); +export const generator: typeof base = { + ...base, + async generateDocumentation(...args) { + const docs = await base.generateDocumentation(...args); + return docs.map(doc => ({ + ...doc, + entries: doc.entries.filter(entry => !hidden.has(entry)) + })); + } +}; + +const isExternal = (node: SourceNode) => + node.getSourceFile().fileName.includes('/node_modules/'); + +/** A prop is shown when Apsara or Base UI declares it. */ +const isShown = (file: string) => + !file.includes('/node_modules/') || file.includes('/node_modules/@base-ui/'); + const shortTypes: Record = { className: 'string | function', style: 'CSSProperties | function', @@ -20,9 +68,8 @@ const SHORT = const withoutUndefined = (type: string) => type.replace(/ \| undefined$/, ''); /** The type as written in the source, which keeps the order of a union. */ -function writtenType(node: unknown): string | undefined { - const type = (node as { type?: { getText(): string } } | undefined)?.type; - const text = type +function writtenType(node: SourceNode | undefined): string | undefined { + const text = node?.type ?.getText() .replace(/\s+/g, ' ') .replace(/^\| /, '') @@ -30,13 +77,95 @@ function writtenType(node: unknown): string | undefined { return text && withoutUndefined(text); } -/** Sets the short type of a table row. The expanded row shows the full type. */ +function commentText(comment: unknown): string { + if (typeof comment === 'string') return comment; + if (!Array.isArray(comment)) return ''; + return comment.map(part => (part as { text?: string }).text ?? '').join(''); +} + +/** The value of a cva variant in `defaultVariants`, for example `"solid"`. */ +function cvaDefault(node: SourceNode, prop: string): string | undefined { + const variants = node.parent?.parent; + if (variants?.name?.text !== 'variants') return; + const defaults = variants.parent?.properties?.find( + property => property.name?.text === 'defaultVariants' + ); + const value = defaults?.initializer?.properties?.find( + property => property.name?.text === prop + ); + return value?.initializer?.getText().replace(/^'(.*)'$/, '"$1"'); +} + +/** + * Hides the props that a table leaves out, and sets the description, default + * and short type of the others. + */ export const transform: Transform = function (entry, _type, prop) { + const declarations = (prop.declarations ?? []).flatMap(declaration => { + const node = declaration.resolve(this.program); + return node ? [node as unknown as SourceNode] : []; + }); + const files = declarations.map(node => node.getSourceFile().fileName); + if (!files.some(isShown) || entry.tags.some(tag => tag.name === 'ignore')) { + hidden.add(entry); + return; + } + + // A prop that Base UI also declares keeps only the Apsara JSDoc. + const own = declarations.filter( + node => !isExternal(node) && (node.jsDoc?.length ?? 0) > 0 + ); + if (own.length > 0 && own.length < declarations.length) { + const docs = own.flatMap(node => node.jsDoc ?? []); + entry.description = docs.map(doc => commentText(doc.comment)).join('\n'); + entry.tags = docs.flatMap(doc => + (doc.tags ?? []).map( + (tag): RawTag => ({ + name: tag.tagName.text, + text: commentText(tag.comment) + }) + ) + ); + entry.deprecated = entry.tags.some(tag => tag.name === 'deprecated'); + } + + const deprecation = entry.tags.find(tag => tag.name === 'deprecated')?.text; + if (deprecation) + entry.description = [entry.description, deprecation] + .filter(Boolean) + .join(' '); + + // An Apsara declaration of the prop wins over one from Base UI. + let written = writtenType( + declarations.find(node => node.type && !isExternal(node)) ?? declarations[0] + ); + // A cva variant has no written type, and cva adds `null` to it. Its values + // keep the order of the variant's keys. + if (!written) { + entry.type = entry.type.replace(/ \| null\b/, ''); + const variant = declarations.find(node => node.initializer)?.initializer; + const checker = this.checker as unknown as Checker; + const keys = variant + ? checker + .getPropertiesOfType(checker.getTypeAtLocation(variant)) + .map(key => key.name) + : []; + if (keys.length > 0 && !keys.includes('true')) + written = keys + .map(key => (/^\d+$/.test(key) ? key : `"${key}"`)) + .join(' | '); + } + if (!entry.tags.some(tag => ['default', 'defaultValue'].includes(tag.name))) { + const value = declarations + .map(node => cvaDefault(node, entry.name)) + .find(Boolean); + if (value) entry.tags.push({ name: 'default', text: value }); + } + // A `@remarks` type set by the author wins. if (entry.tags.some(tag => tag.name === 'remarks' && tag.text[0] === '`')) return; - const written = writtenType(prop.declarations?.[0]?.resolve(this.program)); const type = (written ?? withoutUndefined(entry.type)).replace( /\bReact\./g, '' diff --git a/apps/www/tsconfig.props.json b/apps/www/tsconfig.props.json index 163a0c5a0..3c44ab601 100644 --- a/apps/www/tsconfig.props.json +++ b/apps/www/tsconfig.props.json @@ -1,6 +1,11 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "types": ["react"] + "types": ["react"], + "paths": { + "react": ["./node_modules/@types/react"], + "@raystack/apsara": ["../../packages/react/index.tsx"], + "~/*": ["../../packages/react/*"] + } } } From 755aae76a438fc96266f0745fd0102b8c3c57770 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 29 Oct 2026 17:13:50 +0530 Subject: [PATCH 2/3] feat: [docs, button] generate the button props table from its types --- .../content/docs/components/button/props.ts | 74 +------------------ packages/react/components/button/button.tsx | 16 ++++ packages/react/shared/radius/radius.ts | 5 +- 3 files changed, 21 insertions(+), 74 deletions(-) diff --git a/apps/www/src/content/docs/components/button/props.ts b/apps/www/src/content/docs/components/button/props.ts index 3af27f246..1ec594bc8 100644 --- a/apps/www/src/content/docs/components/button/props.ts +++ b/apps/www/src/content/docs/components/button/props.ts @@ -1,73 +1 @@ -import { ButtonHTMLAttributes } from 'react'; - -export type ButtonProps = { - /** - * Visual style variant - * @defaultValue "solid" - */ - variant?: 'solid' | 'outline' | 'ghost' | 'text'; - - /** - * Color theme - * @defaultValue "accent" - */ - color?: 'accent' | 'danger' | 'neutral' | 'success'; - - /** - * Size of the button - * @defaultValue "normal" - */ - size?: 'small' | 'normal'; - - /** - * Whether the button is disabled - * @defaultValue false - */ - disabled?: boolean; - - /** Shows a loading spinner inside the button */ - loading?: boolean; - - /** Optional text to display next to the loading spinner */ - loaderText?: React.ReactNode; - - /** Icon element to display before button text */ - leadingIcon?: React.ReactNode; - - /** Icon element to display after button text */ - trailingIcon?: React.ReactNode; - - /** - * Whether the component renders a native