chore: import upstream snapshot with attribution
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,496 @@
|
||||
import {
|
||||
Node,
|
||||
type ExportedDeclarations,
|
||||
type InterfaceDeclaration,
|
||||
type ModuleDeclaration,
|
||||
type SourceFile,
|
||||
type TypeAliasDeclaration,
|
||||
} from "ts-morph";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { REACT_INDEX, REACT_PKG, REPO_ROOT } from "./paths.mts";
|
||||
import {
|
||||
getProject,
|
||||
getJsDocCommentText,
|
||||
jsDocTag,
|
||||
jsDocTags,
|
||||
processComponentDeclaration,
|
||||
processTypeOrInterface,
|
||||
type JsDocRenderOptions,
|
||||
type PropModel,
|
||||
} from "./extract.mts";
|
||||
|
||||
export type PrimitivePartModel = {
|
||||
primitiveName: string; // "ComposerPrimitive"
|
||||
partName: string; // "Input"
|
||||
propsTypeName: string; // "ComposerPrimitiveInputProps"
|
||||
/** The local name of the underlying React component declaration. Two
|
||||
* exports that re-export the same component (e.g. `Parts` aliased as
|
||||
* `Content`) share a localName, so the primitive-docs projection can
|
||||
* dedupe to one entry while api-ref still emits a typeDocs entry per
|
||||
* exported part name. */
|
||||
localName: string;
|
||||
element?: string;
|
||||
description?: string;
|
||||
deprecated?: string;
|
||||
examples?: string[];
|
||||
supportsAsChild: boolean;
|
||||
isActionButton: boolean;
|
||||
isRequireAtLeastOne: boolean;
|
||||
props: PropModel[];
|
||||
sourcePath: string;
|
||||
};
|
||||
|
||||
const primitiveSourceFiles = new Map<string, SourceFile>();
|
||||
const primitiveParts = new Map<string, string[]>();
|
||||
let primitiveBarrelExports: Map<string, string> | undefined;
|
||||
|
||||
export function primitivePartTypeDocName(
|
||||
primitiveName: string,
|
||||
part: string,
|
||||
): string {
|
||||
return `${primitiveName}${part}Props`;
|
||||
}
|
||||
|
||||
export function primitiveModuleSourceFile(
|
||||
primitiveName: string,
|
||||
): SourceFile | undefined {
|
||||
const cached = primitiveSourceFiles.get(primitiveName);
|
||||
if (cached) return cached;
|
||||
const project = getProject();
|
||||
const index =
|
||||
project.getSourceFile(REACT_INDEX) ??
|
||||
project.addSourceFileAtPath(REACT_INDEX);
|
||||
const exportDecl = index
|
||||
.getExportDeclarations()
|
||||
.find((decl) => decl.getNamespaceExport()?.getName() === primitiveName);
|
||||
const moduleSpec = exportDecl?.getModuleSpecifierValue();
|
||||
if (!moduleSpec) return undefined;
|
||||
const modulePath = path.join(REACT_PKG, moduleSpec.replace(/^\.\//, ""));
|
||||
const sourcePath = [
|
||||
`${modulePath}.ts`,
|
||||
`${modulePath}.tsx`,
|
||||
path.join(modulePath, "index.ts"),
|
||||
path.join(modulePath, "index.tsx"),
|
||||
].find((file) => fs.existsSync(file));
|
||||
if (!sourcePath) return undefined;
|
||||
const sourceFile =
|
||||
project.getSourceFile(sourcePath) ??
|
||||
project.addSourceFileAtPath(sourcePath);
|
||||
primitiveSourceFiles.set(primitiveName, sourceFile);
|
||||
return sourceFile;
|
||||
}
|
||||
|
||||
export function readPrimitiveParts(primitiveName: string): string[] {
|
||||
const cached = primitiveParts.get(primitiveName);
|
||||
if (cached) return cached;
|
||||
const sourceFile = primitiveModuleSourceFile(primitiveName);
|
||||
if (!sourceFile) {
|
||||
primitiveParts.set(primitiveName, []);
|
||||
return [];
|
||||
}
|
||||
const isPrimitivePart = (name: string) =>
|
||||
(/^[A-Z]/.test(name) || /^Unstable_[A-Z]/.test(name)) &&
|
||||
!name.includes("Primitive");
|
||||
const orderedParts = sourceFile
|
||||
.getExportDeclarations()
|
||||
.flatMap((decl) =>
|
||||
decl.getNamedExports().map((specifier) => {
|
||||
return specifier.getAliasNode()?.getText() ?? specifier.getName();
|
||||
}),
|
||||
)
|
||||
.filter(isPrimitivePart);
|
||||
const orderedPartNames = new Set(orderedParts);
|
||||
const fallbackParts = [...sourceFile.getExportedDeclarations().keys()]
|
||||
.filter(isPrimitivePart)
|
||||
.filter((name) => !orderedPartNames.has(name))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
const parts = [
|
||||
...new Set([
|
||||
...orderedParts.filter((part) => part === "Root"),
|
||||
...orderedParts.filter((part) => part !== "Root"),
|
||||
...fallbackParts,
|
||||
]),
|
||||
];
|
||||
primitiveParts.set(primitiveName, parts);
|
||||
return parts;
|
||||
}
|
||||
|
||||
function findNamespace(
|
||||
sourceFile: SourceFile,
|
||||
localName: string,
|
||||
): ModuleDeclaration | undefined {
|
||||
for (const ns of sourceFile.getModules()) {
|
||||
if (ns.getName() === localName) return ns;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractElementType(ns: ModuleDeclaration): string | undefined {
|
||||
for (const typeAlias of ns.getTypeAliases()) {
|
||||
if (typeAlias.getName() !== "Element") continue;
|
||||
const typeText = typeAlias.getType().getText();
|
||||
if (typeText.includes("HTMLTextAreaElement")) return "textarea";
|
||||
if (typeText.includes("HTMLButtonElement")) return "button";
|
||||
if (typeText.includes("HTMLInputElement")) return "input";
|
||||
if (typeText.includes("HTMLDivElement")) return "div";
|
||||
if (typeText.includes("HTMLSpanElement")) return "span";
|
||||
if (typeText.includes("HTMLFormElement")) return "form";
|
||||
if (typeText.includes("ActionButtonElement")) return "button";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getPrimitiveComponentMeta(
|
||||
sourceFile: SourceFile,
|
||||
localName: string,
|
||||
options?: JsDocRenderOptions,
|
||||
): {
|
||||
description?: string | undefined;
|
||||
deprecated?: string | undefined;
|
||||
examples?: string[] | undefined;
|
||||
} {
|
||||
for (const varDecl of sourceFile.getVariableDeclarations()) {
|
||||
if (varDecl.getName() !== localName) continue;
|
||||
const statement = varDecl.getVariableStatement();
|
||||
if (!statement) continue;
|
||||
const jsDocs = statement.getJsDocs();
|
||||
if (jsDocs.length === 0) continue;
|
||||
const doc = jsDocs[0]!;
|
||||
const description = getJsDocCommentText(
|
||||
doc,
|
||||
`${localName} primitive`,
|
||||
options,
|
||||
);
|
||||
const deprecated = jsDocTag(
|
||||
doc,
|
||||
"deprecated",
|
||||
`${localName} primitive`,
|
||||
options,
|
||||
);
|
||||
const examples = jsDocTags(
|
||||
doc,
|
||||
"example",
|
||||
`${localName} primitive`,
|
||||
options,
|
||||
);
|
||||
return { description, deprecated, examples };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function typeSupportsAsChild(
|
||||
typeAlias: TypeAliasDeclaration | InterfaceDeclaration,
|
||||
): boolean {
|
||||
return typeAlias
|
||||
.getType()
|
||||
.getProperties()
|
||||
.some((prop) => prop.getName() === "asChild");
|
||||
}
|
||||
|
||||
function referencesRequireAtLeastOne(
|
||||
decl: TypeAliasDeclaration | InterfaceDeclaration,
|
||||
sourceFile: SourceFile,
|
||||
visited = new Set<string>(),
|
||||
): boolean {
|
||||
const key = decl.getName();
|
||||
if (visited.has(key)) return false;
|
||||
visited.add(key);
|
||||
const text = decl.getText();
|
||||
if (text.includes("RequireAtLeastOne")) return true;
|
||||
const typeNodeText =
|
||||
"getTypeNode" in decl ? (decl.getTypeNode?.()?.getText() ?? text) : text;
|
||||
const referencedNames = new Set(
|
||||
Array.from(typeNodeText.matchAll(/\b[A-Z][A-Za-z0-9_]*\b/g)).map(
|
||||
(match) => match[0],
|
||||
),
|
||||
);
|
||||
for (const name of referencedNames) {
|
||||
if (name === key || name === "PropsWithChildren") continue;
|
||||
const referencedType = sourceFile.getTypeAlias(name);
|
||||
if (
|
||||
referencedType &&
|
||||
referencesRequireAtLeastOne(referencedType, sourceFile, visited)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const referencedInterface = sourceFile.getInterface(name);
|
||||
if (
|
||||
referencedInterface &&
|
||||
referencesRequireAtLeastOne(referencedInterface, sourceFile, visited)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function extractPropsFromComponentDeclaration(
|
||||
sourceFile: SourceFile,
|
||||
localName: string,
|
||||
ownerTypeName: string,
|
||||
options?: JsDocRenderOptions,
|
||||
): PropModel[] | undefined {
|
||||
const propsTypeNames = new Set<string>();
|
||||
const variableDecl = sourceFile
|
||||
.getVariableDeclarations()
|
||||
.find((decl) => decl.getName() === localName);
|
||||
const typeNodeText = variableDecl?.getTypeNode()?.getText();
|
||||
if (typeNodeText) {
|
||||
for (const match of typeNodeText.matchAll(
|
||||
/<\s*([A-Za-z0-9_]+Props)\s*>/g,
|
||||
)) {
|
||||
propsTypeNames.add(match[1]!);
|
||||
}
|
||||
}
|
||||
if (propsTypeNames.size === 0) {
|
||||
const suffix = localName.replace(/^[A-Za-z]+Primitive/, "");
|
||||
propsTypeNames.add(`${suffix}Props`);
|
||||
}
|
||||
for (const propsTypeName of propsTypeNames) {
|
||||
const typeAlias = sourceFile.getTypeAlias(propsTypeName);
|
||||
if (typeAlias) {
|
||||
return processTypeOrInterface(typeAlias, ownerTypeName, options);
|
||||
}
|
||||
const iface = sourceFile.getInterface(propsTypeName);
|
||||
if (iface) return processTypeOrInterface(iface, ownerTypeName, options);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function discoverPrimitiveBarrelExports(): Map<string, string> {
|
||||
if (primitiveBarrelExports) return primitiveBarrelExports;
|
||||
const project = getProject();
|
||||
const result = new Map<string, string>();
|
||||
const indexFile =
|
||||
project.getSourceFile(REACT_INDEX) ??
|
||||
project.addSourceFileAtPath(REACT_INDEX);
|
||||
for (const decl of indexFile.getExportDeclarations()) {
|
||||
const namespaceExport = decl.getNamespaceExport();
|
||||
if (!namespaceExport) continue;
|
||||
const name = namespaceExport.getName();
|
||||
const moduleSpec = decl.getModuleSpecifierValue();
|
||||
if (name.endsWith("Primitive") && moduleSpec) {
|
||||
result.set(name, moduleSpec);
|
||||
}
|
||||
}
|
||||
primitiveBarrelExports = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
type SubComponent = {
|
||||
exportedName: string;
|
||||
declaration: ExportedDeclarations;
|
||||
};
|
||||
|
||||
function discoverSubComponents(primitiveModulePath: string): SubComponent[] {
|
||||
const candidatePaths = [
|
||||
`${primitiveModulePath}.ts`,
|
||||
`${primitiveModulePath}.tsx`,
|
||||
path.join(primitiveModulePath, "index.ts"),
|
||||
path.join(primitiveModulePath, "index.tsx"),
|
||||
];
|
||||
const indexPath = candidatePaths.find((candidate) =>
|
||||
fs.existsSync(candidate),
|
||||
);
|
||||
if (!indexPath) return [];
|
||||
const project = getProject();
|
||||
let sourceFile: SourceFile;
|
||||
try {
|
||||
sourceFile =
|
||||
project.getSourceFile(indexPath) ??
|
||||
project.addSourceFileAtPath(indexPath);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const components: SubComponent[] = [];
|
||||
for (const [
|
||||
exportedName,
|
||||
declarations,
|
||||
] of sourceFile.getExportedDeclarations()) {
|
||||
// Accept capital and lowercase `unstable_` prefixes — the lowercase form
|
||||
// matches legacy primitive-docs (e.g. AttachmentPrimitive.unstable_Thumb).
|
||||
// The api-ref typeDocs projection filters lowercase `unstable_` parts
|
||||
// back out to match legacy api-surface behaviour.
|
||||
const isPart =
|
||||
(/^[A-Z]/.test(exportedName) ||
|
||||
/^Unstable_[A-Z]/.test(exportedName) ||
|
||||
/^unstable_[A-Z]/.test(exportedName)) &&
|
||||
!exportedName.includes("Primitive");
|
||||
if (!isPart) continue;
|
||||
const declaration = declarations.find((decl) => {
|
||||
if (Node.isVariableDeclaration(decl)) return true;
|
||||
if (Node.isFunctionDeclaration(decl)) return true;
|
||||
if (Node.isClassDeclaration(decl)) return true;
|
||||
return false;
|
||||
});
|
||||
if (!declaration) continue;
|
||||
components.push({ exportedName, declaration });
|
||||
}
|
||||
return components;
|
||||
}
|
||||
|
||||
function localNameFor(declaration: ExportedDeclarations): string | undefined {
|
||||
const symbolName = declaration.getSymbol()?.getName();
|
||||
if (symbolName) return symbolName;
|
||||
if (
|
||||
Node.isVariableDeclaration(declaration) ||
|
||||
Node.isFunctionDeclaration(declaration) ||
|
||||
Node.isClassDeclaration(declaration) ||
|
||||
Node.isInterfaceDeclaration(declaration) ||
|
||||
Node.isTypeAliasDeclaration(declaration) ||
|
||||
Node.isModuleDeclaration(declaration)
|
||||
) {
|
||||
return declaration.getName();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractPrimitivePart(
|
||||
primitiveName: string,
|
||||
sub: SubComponent,
|
||||
options?: JsDocRenderOptions,
|
||||
): PrimitivePartModel | undefined {
|
||||
const sourceFile = sub.declaration.getSourceFile();
|
||||
const localName = localNameFor(sub.declaration);
|
||||
if (!localName) return undefined;
|
||||
const ns = findNamespace(sourceFile, localName);
|
||||
const propsAlias = ns?.getTypeAliases().find((t) => t.getName() === "Props");
|
||||
const element = ns ? extractElementType(ns) : undefined;
|
||||
const { description, deprecated, examples } = getPrimitiveComponentMeta(
|
||||
sourceFile,
|
||||
localName,
|
||||
options,
|
||||
);
|
||||
|
||||
let props: PropModel[] | undefined;
|
||||
let isActionButton = false;
|
||||
let supportsAsChild = false;
|
||||
let isRequireAtLeastOne = false;
|
||||
const propsTypeName = primitivePartTypeDocName(
|
||||
primitiveName,
|
||||
sub.exportedName,
|
||||
);
|
||||
|
||||
if (propsAlias) {
|
||||
const propsText = propsAlias.getText();
|
||||
isActionButton = propsText.includes("ActionButtonProps");
|
||||
supportsAsChild = typeSupportsAsChild(propsAlias);
|
||||
isRequireAtLeastOne = referencesRequireAtLeastOne(propsAlias, sourceFile);
|
||||
// Process the Props alias uniformly — including ActionButton-style parts.
|
||||
// The intersection naturally surfaces both PrimitiveButtonProps members
|
||||
// (asChild, render, … tagged inheritedFrom: "radix") and the hook-specific
|
||||
// params (tagged inheritedFrom: undefined). Each projection then filters
|
||||
// according to its policy.
|
||||
props = processTypeOrInterface(propsAlias, propsTypeName, options) ?? [];
|
||||
// RequireAtLeastOne: every prop is technically optional individually,
|
||||
// but at least one must be supplied. Surface this by marking all props
|
||||
// optional in the model so projections render them consistently.
|
||||
if (isRequireAtLeastOne) {
|
||||
props = props.map((p) => ({ ...p, required: false }));
|
||||
}
|
||||
} else {
|
||||
props = extractPropsFromComponentDeclaration(
|
||||
sourceFile,
|
||||
localName,
|
||||
propsTypeName,
|
||||
options,
|
||||
);
|
||||
// Fallback: when neither a Props alias nor a *Props type by name is
|
||||
// available (e.g. wrappers built with `Object.assign(Base, ...)` lose
|
||||
// the namespace), fall back to inspecting the component's call signature
|
||||
// parameter directly. Mirrors legacy api-surface's `primitivePartTypeDoc`
|
||||
// behaviour so e.g. `Unstable_TriggerPopover` still gets typed props.
|
||||
if (!props) {
|
||||
props = processComponentDeclaration(
|
||||
sub.declaration,
|
||||
propsTypeName,
|
||||
options,
|
||||
);
|
||||
}
|
||||
// Detect asChild from the resolved component props (no namespace alias
|
||||
// available, so check the projected props instead).
|
||||
if (props?.some((p) => p.name === "asChild")) {
|
||||
supportsAsChild = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!props) return undefined;
|
||||
|
||||
const sourcePath = path
|
||||
.relative(REPO_ROOT, sourceFile.getFilePath())
|
||||
.replaceAll("\\", "/");
|
||||
|
||||
const model: PrimitivePartModel = {
|
||||
primitiveName,
|
||||
partName: sub.exportedName,
|
||||
propsTypeName,
|
||||
localName,
|
||||
supportsAsChild,
|
||||
isActionButton,
|
||||
isRequireAtLeastOne,
|
||||
props,
|
||||
sourcePath,
|
||||
};
|
||||
if (element) model.element = element;
|
||||
if (description) model.description = description;
|
||||
if (deprecated) model.deprecated = deprecated;
|
||||
if (examples) model.examples = examples;
|
||||
return model;
|
||||
}
|
||||
|
||||
const _partsCache = new Map<string, PrimitivePartModel[]>();
|
||||
|
||||
export function extractPrimitivePartsFor(
|
||||
primitiveName: string,
|
||||
options?: JsDocRenderOptions,
|
||||
): PrimitivePartModel[] {
|
||||
const cacheKey = options?.linkResolver
|
||||
? `${primitiveName}:links`
|
||||
: primitiveName;
|
||||
const cached = _partsCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
const primitives = discoverPrimitiveBarrelExports();
|
||||
const moduleSpec = primitives.get(primitiveName);
|
||||
if (!moduleSpec) {
|
||||
_partsCache.set(cacheKey, []);
|
||||
return [];
|
||||
}
|
||||
const primitiveModulePath = path.join(
|
||||
REACT_PKG,
|
||||
moduleSpec.replace(/^\.\//, ""),
|
||||
);
|
||||
const subs = discoverSubComponents(primitiveModulePath);
|
||||
const result: PrimitivePartModel[] = [];
|
||||
// Emit one model per exported part name, even when several exports share
|
||||
// the same local declaration. The primitive-docs projection dedupes by
|
||||
// localName so it still produces one entry per component, while api-ref
|
||||
// gets a typeDocs entry per exported name (matching legacy api-surface).
|
||||
for (const sub of subs) {
|
||||
try {
|
||||
const part = extractPrimitivePart(primitiveName, sub, options);
|
||||
if (part) result.push(part);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
` Warning: Failed to extract ${primitiveName}.${sub.exportedName}:`,
|
||||
(e as Error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
_partsCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Extract every primitive part across every primitive. Used by
|
||||
* generate-type-docs.mts. Pass the react api render options so {@link}
|
||||
* references in primitive prop JSDoc resolve to anchors (and only genuinely
|
||||
* broken links warn), matching the api-reference pass. */
|
||||
export function extractPrimitiveParts(
|
||||
options?: JsDocRenderOptions,
|
||||
): PrimitivePartModel[] {
|
||||
const result: PrimitivePartModel[] = [];
|
||||
for (const primitiveName of discoverPrimitiveBarrelExports().keys()) {
|
||||
result.push(...extractPrimitivePartsFor(primitiveName, options));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user