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,676 @@
|
||||
import type { ApiSection, ExportInfo, ExportKind } from "./discover.mts";
|
||||
|
||||
export type Classification = {
|
||||
section: ApiSection;
|
||||
page: string;
|
||||
role: ExportInfo["pageRole"];
|
||||
rule: string;
|
||||
confidence: ExportInfo["classificationConfidence"];
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type ClassificationInput = {
|
||||
name: string;
|
||||
kind: ExportKind;
|
||||
sourcePath?: string | undefined;
|
||||
};
|
||||
|
||||
type ClassificationRule = (
|
||||
input: ClassificationInput,
|
||||
) => Classification | undefined;
|
||||
|
||||
type DocPlacement = { page: string; role: ExportInfo["pageRole"] };
|
||||
|
||||
const PRIMARY_FEATURE_TYPES = new Set([
|
||||
"RealtimeVoiceAdapter",
|
||||
"VoiceSessionState",
|
||||
"VoiceSessionControls",
|
||||
"VoiceSessionHelpers",
|
||||
"SpeechSynthesisAdapter",
|
||||
"DictationAdapter",
|
||||
"DictationState",
|
||||
"ExternalStoreAdapter",
|
||||
"ExternalThreadQueueAdapter",
|
||||
"ExternalThreadProps",
|
||||
]);
|
||||
|
||||
const RUNTIME_CREATION_HOOKS = new Set([
|
||||
"useAssistantTransportRuntime",
|
||||
"useCloudThreadListRuntime",
|
||||
"useExternalStoreRuntime",
|
||||
"useLocalRuntime",
|
||||
"useLocalThreadRuntime",
|
||||
"useRemoteThreadListRuntime",
|
||||
"unstable_useRemoteThreadListRuntime",
|
||||
]);
|
||||
|
||||
const STATE_HOOKS = new Set([
|
||||
"useAui",
|
||||
"useAuiState",
|
||||
"useAuiEvent",
|
||||
"useAssistantApi",
|
||||
"useAssistantState",
|
||||
"useAssistantEvent",
|
||||
]);
|
||||
|
||||
const COMPOSER_TRIGGER_HOOKS = new Set([
|
||||
"unstable_useMentionAdapter",
|
||||
"unstable_useSlashCommandAdapter",
|
||||
"unstable_useLiveCompletionAdapter",
|
||||
"unstable_useTriggerPopoverRootContext",
|
||||
"unstable_useTriggerPopoverRootContextOptional",
|
||||
"unstable_useTriggerPopoverScopeContext",
|
||||
"unstable_useTriggerPopoverScopeContextOptional",
|
||||
"unstable_useTriggerPopoverTriggers",
|
||||
"unstable_useTriggerPopoverTriggersOptional",
|
||||
]);
|
||||
|
||||
function kebabCase(value: string): string {
|
||||
return value
|
||||
.replace(/^unstable_/, "unstable-")
|
||||
.replace(/Primitive$/, "")
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function supportingTypeRole(kind: ExportKind): ExportInfo["pageRole"] {
|
||||
return kind === "interface" || kind === "type"
|
||||
? "supporting-type"
|
||||
: "primary";
|
||||
}
|
||||
|
||||
// Like supportingTypeRole, but values render as "related" rather than "primary"
|
||||
// so a confidently-classified page's core APIs sort above them. Used for
|
||||
// loosely-placed exports (suffix fallback, co-location) that earned a spot on a
|
||||
// page but should not lead it.
|
||||
export function relatedOrSupportingRole(
|
||||
kind: ExportKind,
|
||||
): ExportInfo["pageRole"] {
|
||||
return kind === "interface" || kind === "type"
|
||||
? "supporting-type"
|
||||
: "related";
|
||||
}
|
||||
|
||||
export function pageForToolExport(name: string): string {
|
||||
if (
|
||||
name === "makeAssistantTool" ||
|
||||
name === "useAssistantTool" ||
|
||||
name === "AssistantTool" ||
|
||||
name === "AssistantToolProps"
|
||||
) {
|
||||
return "component-tools";
|
||||
}
|
||||
if (name === "DataRenderers") {
|
||||
return "rendering";
|
||||
}
|
||||
if (
|
||||
name.includes("ToolArgs") ||
|
||||
name.includes("ToolExecution") ||
|
||||
name.includes("ToolCall")
|
||||
) {
|
||||
return "status";
|
||||
}
|
||||
if (
|
||||
name.includes("ToolUI") ||
|
||||
name.includes("DataUI") ||
|
||||
name === "makeAssistantDataUI" ||
|
||||
name === "useAssistantDataUI"
|
||||
) {
|
||||
return "rendering";
|
||||
}
|
||||
return "toolkits";
|
||||
}
|
||||
|
||||
function classification(
|
||||
section: ApiSection,
|
||||
page: string,
|
||||
role: ExportInfo["pageRole"],
|
||||
rule: string,
|
||||
confidence: Classification["confidence"],
|
||||
reason: string,
|
||||
): Classification {
|
||||
return { section, page, role, rule, confidence, reason };
|
||||
}
|
||||
|
||||
function toolsRule(input: ClassificationInput): Classification | undefined {
|
||||
const { name, kind } = input;
|
||||
if (
|
||||
name === "tool" ||
|
||||
name === "Tool" ||
|
||||
name === "Toolkit" ||
|
||||
name === "ToolDefinition" ||
|
||||
name === "ToolsConfig" ||
|
||||
name === "McpAppResourceOutput" ||
|
||||
name === "useToolArgsStatus" ||
|
||||
name === "Tools" ||
|
||||
name === "DataRenderers" ||
|
||||
name.includes("AssistantTool") ||
|
||||
name.includes("AssistantDataUI") ||
|
||||
name.includes("ToolArgs") ||
|
||||
name.includes("ToolExecution") ||
|
||||
name.includes("ToolCall")
|
||||
) {
|
||||
return classification(
|
||||
"tools",
|
||||
pageForToolExport(name),
|
||||
name === "Toolkit" || name === "ToolDefinition"
|
||||
? "primary"
|
||||
: supportingTypeRole(kind),
|
||||
"feature:tools",
|
||||
"strong",
|
||||
"tool definition, toolkit, component registration, rendering, or status export",
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function transportRule(input: ClassificationInput): Classification | undefined {
|
||||
const { name } = input;
|
||||
if (
|
||||
name.includes("AssistantTransport") ||
|
||||
name.includes("SendCommands") ||
|
||||
name.includes("Frame") ||
|
||||
name.startsWith("Serialized") ||
|
||||
name === "FRAME_MESSAGE_CHANNEL"
|
||||
) {
|
||||
// Transport frame/protocol helpers intentionally render as primary entries:
|
||||
// the page split already groups them, and demoting them hides important protocol docs.
|
||||
return classification(
|
||||
"transport",
|
||||
name.includes("Frame") ||
|
||||
name.startsWith("Serialized") ||
|
||||
name === "FRAME_MESSAGE_CHANNEL"
|
||||
? "frame"
|
||||
: "assistant-transport",
|
||||
"primary",
|
||||
"feature:transport",
|
||||
"strong",
|
||||
"assistant transport, frame, or protocol export",
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function externalStoreRule(
|
||||
input: ClassificationInput,
|
||||
): Classification | undefined {
|
||||
const { name, kind } = input;
|
||||
if (
|
||||
name.includes("ExternalStore") ||
|
||||
name.includes("ExternalThread") ||
|
||||
name.includes("ExternalMessage") ||
|
||||
name.includes("MessageConverter") ||
|
||||
name === "getExternalStoreMessages" ||
|
||||
name === "bindExternalStoreMessage" ||
|
||||
name === "unstable_convertExternalMessages" ||
|
||||
name === "unstable_createMessageConverter"
|
||||
) {
|
||||
return classification(
|
||||
"external-store",
|
||||
name.includes("Message") || name.includes("Converter")
|
||||
? "message-conversion"
|
||||
: "runtime",
|
||||
PRIMARY_FEATURE_TYPES.has(name) ? "primary" : supportingTypeRole(kind),
|
||||
"feature:external-store",
|
||||
"strong",
|
||||
"external store runtime or message conversion export",
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function modelContextRule(
|
||||
input: ClassificationInput,
|
||||
): Classification | undefined {
|
||||
const { name, kind } = input;
|
||||
if (
|
||||
name.includes("ModelContext") ||
|
||||
name.includes("AssistantContext") ||
|
||||
name.includes("AssistantInstructions") ||
|
||||
name.includes("InlineRender") ||
|
||||
name === "mergeModelContexts" ||
|
||||
name === "ModelContextRegistry"
|
||||
) {
|
||||
return classification(
|
||||
"model-context",
|
||||
name.includes("Registry") ? "registry" : "context",
|
||||
supportingTypeRole(kind),
|
||||
"feature:model-context",
|
||||
"strong",
|
||||
"model context, instructions, context, or registry export",
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function voiceRule(input: ClassificationInput): Classification | undefined {
|
||||
const { name, kind } = input;
|
||||
if (
|
||||
name.includes("Voice") ||
|
||||
name.includes("Speech") ||
|
||||
name.includes("Dictation") ||
|
||||
name === "createVoiceSession"
|
||||
) {
|
||||
return classification(
|
||||
"voice",
|
||||
name.includes("Speech") || name.includes("Dictation")
|
||||
? "speech-dictation"
|
||||
: "session",
|
||||
PRIMARY_FEATURE_TYPES.has(name) ? "primary" : supportingTypeRole(kind),
|
||||
"feature:voice",
|
||||
"strong",
|
||||
"voice, speech, or dictation export",
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const GENERATIVE_UI_SPEC_TYPES = new Set([
|
||||
"GenerativeUISpec",
|
||||
"GenerativeUINode",
|
||||
"GenerativeUIMessagePart",
|
||||
]);
|
||||
|
||||
export const GENERATIVE_UI_PACKAGE_EXPORTS = new Map<
|
||||
string,
|
||||
{ page: string; role: ExportInfo["pageRole"] }
|
||||
>([
|
||||
["JSONGenerativeUI", { page: "json-generative-ui", role: "primary" }],
|
||||
["JSONGenerativeUIOptions", { page: "json-generative-ui", role: "primary" }],
|
||||
["PresentToolOptions", { page: "json-generative-ui", role: "primary" }],
|
||||
["PresentTool", { page: "json-generative-ui", role: "supporting-type" }],
|
||||
["PromptUserTool", { page: "json-generative-ui", role: "supporting-type" }],
|
||||
|
||||
["defineGenerativeComponents", { page: "components", role: "primary" }],
|
||||
["defaultGenerativeUILibrary", { page: "components", role: "primary" }],
|
||||
["GenerativeUILibrary", { page: "components", role: "primary" }],
|
||||
["GenerativeUIComponent", { page: "components", role: "primary" }],
|
||||
|
||||
["createActionRegistry", { page: "actions", role: "primary" }],
|
||||
["emptyActionRegistry", { page: "actions", role: "primary" }],
|
||||
["ActionRegistry", { page: "actions", role: "primary" }],
|
||||
["ActionHandler", { page: "actions", role: "primary" }],
|
||||
["ActionDispatchContext", { page: "actions", role: "primary" }],
|
||||
|
||||
["renderGenerativeUI", { page: "rendering", role: "primary" }],
|
||||
["generativeUIToJSX", { page: "rendering", role: "primary" }],
|
||||
["buildPresentParameters", { page: "rendering", role: "primary" }],
|
||||
["GenerativeUIRenderContext", { page: "rendering", role: "primary" }],
|
||||
|
||||
["TEXT_SIZES", { page: "tokens", role: "primary" }],
|
||||
["IMAGE_SIZE_TOKENS", { page: "tokens", role: "primary" }],
|
||||
["WEIGHTS", { page: "tokens", role: "primary" }],
|
||||
["COLORS", { page: "tokens", role: "primary" }],
|
||||
["ALIGNS", { page: "tokens", role: "primary" }],
|
||||
["JUSTIFIES", { page: "tokens", role: "primary" }],
|
||||
["BUTTON_STYLES", { page: "tokens", role: "primary" }],
|
||||
["ALERT_TONES", { page: "tokens", role: "primary" }],
|
||||
["TextSize", { page: "tokens", role: "supporting-type" }],
|
||||
["ImageSize", { page: "tokens", role: "supporting-type" }],
|
||||
["Weight", { page: "tokens", role: "supporting-type" }],
|
||||
["Color", { page: "tokens", role: "supporting-type" }],
|
||||
["Align", { page: "tokens", role: "supporting-type" }],
|
||||
["Justify", { page: "tokens", role: "supporting-type" }],
|
||||
["ButtonStyle", { page: "tokens", role: "supporting-type" }],
|
||||
["AlertTone", { page: "tokens", role: "supporting-type" }],
|
||||
]);
|
||||
|
||||
function generativeUIPackageRule(
|
||||
input: ClassificationInput,
|
||||
): Classification | undefined {
|
||||
const placement = GENERATIVE_UI_PACKAGE_EXPORTS.get(input.name);
|
||||
if (!placement) return undefined;
|
||||
return classification(
|
||||
"generative-ui",
|
||||
placement.page,
|
||||
placement.role,
|
||||
"feature:react-generative-ui",
|
||||
"strong",
|
||||
"@assistant-ui/react-generative-ui package export",
|
||||
);
|
||||
}
|
||||
|
||||
function generativeUIRule(
|
||||
input: ClassificationInput,
|
||||
): Classification | undefined {
|
||||
const { name, kind } = input;
|
||||
if (!name.startsWith("GenerativeUI")) return undefined;
|
||||
// The spec format types are the cross-reference targets
|
||||
// ({@link GenerativeUISpec}, {@link GenerativeUIMessagePart}); they must be
|
||||
// primary to earn an anchor. The renderer, error, and registry/prop helpers
|
||||
// live on the rendering page.
|
||||
const isSpecType = GENERATIVE_UI_SPEC_TYPES.has(name);
|
||||
return classification(
|
||||
"generative-ui",
|
||||
isSpecType ? "spec" : "rendering",
|
||||
isSpecType ? "primary" : supportingTypeRole(kind),
|
||||
"feature:generative-ui",
|
||||
"strong",
|
||||
"generative UI spec, renderer, or component registry export",
|
||||
);
|
||||
}
|
||||
|
||||
const LEGACY_INTERACTABLE_EXPORTS = new Set([
|
||||
"useAssistantInteractable",
|
||||
"AssistantInteractableProps",
|
||||
"useInteractableState",
|
||||
"Interactables",
|
||||
"InteractableStateSchema",
|
||||
"InteractablesState",
|
||||
"InteractableDefinition",
|
||||
"InteractableRegistration",
|
||||
"InteractablesMethods",
|
||||
"InteractablePersistedState",
|
||||
"InteractablePersistenceAdapter",
|
||||
"InteractablePersistenceStatus",
|
||||
"InteractablesClientSchema",
|
||||
]);
|
||||
|
||||
function interactablesRule(
|
||||
input: ClassificationInput,
|
||||
): Classification | undefined {
|
||||
const { name, kind } = input;
|
||||
const isUnstableInteractable =
|
||||
name.startsWith("unstable_") && name.toLowerCase().includes("interactable");
|
||||
const isUnstableInteractableType =
|
||||
name.startsWith("Unstable_") && name.includes("Interactable");
|
||||
|
||||
if (isUnstableInteractable || isUnstableInteractableType) {
|
||||
return classification(
|
||||
"tools",
|
||||
"interactables",
|
||||
supportingTypeRole(kind),
|
||||
"feature:interactables",
|
||||
"strong",
|
||||
"unstable interactables API export",
|
||||
);
|
||||
}
|
||||
|
||||
if (LEGACY_INTERACTABLE_EXPORTS.has(name)) {
|
||||
return classification(
|
||||
"tools",
|
||||
"interactables-legacy",
|
||||
supportingTypeRole(kind),
|
||||
"feature:interactables-legacy",
|
||||
"strong",
|
||||
"legacy interactables API export",
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function kindRule(input: ClassificationInput): Classification | undefined {
|
||||
const { name, sourcePath } = input;
|
||||
let section: ApiSection | undefined;
|
||||
let forcedPrimary = false;
|
||||
if (
|
||||
name === "AuiIf" ||
|
||||
name === "AssistantIf" ||
|
||||
name.endsWith("Primitive")
|
||||
) {
|
||||
section = "primitives";
|
||||
} else if (/^(unstable_)?use[A-Z]/.test(name)) {
|
||||
section = "hooks";
|
||||
} else if (name.endsWith("Provider")) {
|
||||
section = "context-providers";
|
||||
} else if (/Adapter(s)?$/.test(name)) {
|
||||
section = "adapters";
|
||||
} else if (/(Runtime|State)$/.test(name)) {
|
||||
section = "runtimes";
|
||||
} else if (
|
||||
[
|
||||
"ChatModelRunOptions",
|
||||
"ChatModelRunResult",
|
||||
"ChatModelRunUpdate",
|
||||
"CreateStartRunConfig",
|
||||
"CreateResumeRunConfig",
|
||||
"LanguageModelConfig",
|
||||
].includes(name)
|
||||
) {
|
||||
section = "adapters";
|
||||
forcedPrimary = true;
|
||||
}
|
||||
|
||||
if (!section) return undefined;
|
||||
const placement = inferKindDocPlacement(name, section, sourcePath);
|
||||
if (!placement) return undefined;
|
||||
return classification(
|
||||
section,
|
||||
placement.page,
|
||||
forcedPrimary ? "primary" : placement.role,
|
||||
`kind:${section}`,
|
||||
"medium",
|
||||
`${section} export matched by public API shape`,
|
||||
);
|
||||
}
|
||||
|
||||
function fallbackRule(input: ClassificationInput): Classification {
|
||||
// Suffix match routes *Tool/*Toolkit helpers the exact-name list misses.
|
||||
// Value helpers render as "related" so the page's core tool APIs (tool,
|
||||
// Toolkit, ToolDefinition) sort above them; types stay supporting.
|
||||
if (/Tool(kit)?$/.test(input.name)) {
|
||||
return classification(
|
||||
"tools",
|
||||
pageForToolExport(input.name),
|
||||
relatedOrSupportingRole(input.kind),
|
||||
"fallback:name",
|
||||
"medium",
|
||||
"name ends in Tool/Toolkit",
|
||||
);
|
||||
}
|
||||
return classification(
|
||||
"utilities",
|
||||
"miscellaneous",
|
||||
supportingTypeRole(input.kind),
|
||||
"fallback:utilities",
|
||||
"fallback",
|
||||
"no feature or kind rule matched",
|
||||
);
|
||||
}
|
||||
|
||||
const CLASSIFICATION_RULES: ClassificationRule[] = [
|
||||
toolsRule,
|
||||
transportRule,
|
||||
externalStoreRule,
|
||||
modelContextRule,
|
||||
voiceRule,
|
||||
generativeUIPackageRule,
|
||||
generativeUIRule,
|
||||
interactablesRule,
|
||||
kindRule,
|
||||
];
|
||||
|
||||
/** Exact-name overrides consulted before any heuristic. The escape hatch for
|
||||
* exports the rules can't place from name/source shape alone (mirrors
|
||||
* MANUAL_API_REFERENCE_LINKS in discover.mts): pinning one is a single entry
|
||||
* here instead of contorting a regex rule. */
|
||||
const MANUAL_CLASSIFICATIONS = new Map<
|
||||
string,
|
||||
{ section: ApiSection; page: string; role: ExportInfo["pageRole"] }
|
||||
>([
|
||||
// Constructor for the `groupBy` prop of <MessagePrimitive.GroupedParts>; its
|
||||
// home is the message primitive page, beside the part it configures.
|
||||
[
|
||||
"groupPartByType",
|
||||
{ section: "primitives", page: "message", role: "related" },
|
||||
],
|
||||
// MCP app embedding/rendering surface.
|
||||
["McpAppRenderer", { section: "tools", page: "rendering", role: "primary" }],
|
||||
[
|
||||
"McpAppsRemoteHost",
|
||||
{ section: "tools", page: "rendering", role: "primary" },
|
||||
],
|
||||
[
|
||||
"getMcpAppFromToolPart",
|
||||
{ section: "tools", page: "rendering", role: "primary" },
|
||||
],
|
||||
// Marks a component subtree visible to the assistant's model context.
|
||||
[
|
||||
"makeAssistantVisible",
|
||||
{ section: "model-context", page: "context", role: "primary" },
|
||||
],
|
||||
// Serializable thread/message snapshot used for persistence.
|
||||
[
|
||||
"ExportedMessageRepository",
|
||||
{ section: "adapters", page: "persistence", role: "primary" },
|
||||
],
|
||||
]);
|
||||
|
||||
export function classifyExport(input: ClassificationInput): Classification {
|
||||
const manual = MANUAL_CLASSIFICATIONS.get(input.name);
|
||||
if (manual) {
|
||||
return classification(
|
||||
manual.section,
|
||||
manual.page,
|
||||
manual.role,
|
||||
"manual",
|
||||
"strong",
|
||||
"manually pinned classification",
|
||||
);
|
||||
}
|
||||
for (const rule of CLASSIFICATION_RULES) {
|
||||
const result = rule(input);
|
||||
if (result) return result;
|
||||
}
|
||||
return fallbackRule(input);
|
||||
}
|
||||
|
||||
export function inferKindDocPlacement(
|
||||
name: string,
|
||||
section: ApiSection,
|
||||
sourcePath?: string,
|
||||
): DocPlacement | undefined {
|
||||
const source = (sourcePath ?? "").replaceAll("\\", "/");
|
||||
if (section === "primitives") {
|
||||
if (name === "AuiIf") return { page: "assistant-if", role: "primary" };
|
||||
if (name === "AssistantIf")
|
||||
return { page: "assistant-if", role: "related" };
|
||||
return { page: kebabCase(name), role: "primary" };
|
||||
}
|
||||
|
||||
if (section === "hooks") {
|
||||
if (STATE_HOOKS.has(name)) {
|
||||
return { page: "state", role: "primary" };
|
||||
}
|
||||
if (RUNTIME_CREATION_HOOKS.has(name)) {
|
||||
return { page: "runtimes", role: "primary" };
|
||||
}
|
||||
if (COMPOSER_TRIGGER_HOOKS.has(name)) {
|
||||
return { page: "composer-triggers", role: "primary" };
|
||||
}
|
||||
if (source.includes("assistant-transport")) {
|
||||
return { page: "assistant-transport", role: "primary" };
|
||||
}
|
||||
if (source.includes("voice") || name.includes("Voice")) {
|
||||
return { page: "voice", role: "primary" };
|
||||
}
|
||||
if (
|
||||
name.includes("AssistantTool") ||
|
||||
name.includes("AssistantData") ||
|
||||
name.includes("AssistantInstructions") ||
|
||||
name.includes("AssistantContext") ||
|
||||
name.includes("InlineRender") ||
|
||||
name.includes("Interactable") ||
|
||||
name.includes("ToolArgs") ||
|
||||
source.includes("model-context")
|
||||
) {
|
||||
return { page: "model-context", role: "primary" };
|
||||
}
|
||||
if (
|
||||
source.includes("/primitives/") ||
|
||||
source.includes("/legacy-runtime/hooks/") ||
|
||||
source.includes("/runtimes/cloud/") ||
|
||||
name.includes("Attachment") ||
|
||||
name.includes("Composer") ||
|
||||
name.includes("Message") ||
|
||||
name.includes("Quote") ||
|
||||
name.includes("RuntimeAdapters") ||
|
||||
name.includes("Scroll") ||
|
||||
name.includes("Thread") ||
|
||||
name.includes("Viewport")
|
||||
) {
|
||||
return { page: "primitives", role: "primary" };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (section === "adapters") {
|
||||
if (
|
||||
name === "ChatModelAdapter" ||
|
||||
name.includes("ChatModel") ||
|
||||
name.includes("LanguageModel") ||
|
||||
name.includes("RunConfig")
|
||||
) {
|
||||
return { page: "model", role: "primary" };
|
||||
}
|
||||
if (name === "FeedbackAdapter") {
|
||||
return { page: "feedback", role: "primary" };
|
||||
}
|
||||
if (name.includes("Attachment"))
|
||||
return { page: "attachments", role: "primary" };
|
||||
if (
|
||||
name.includes("Thread") ||
|
||||
name.includes("History") ||
|
||||
name.includes("ExternalStore") ||
|
||||
name.includes("MessageFormat")
|
||||
) {
|
||||
return { page: "persistence", role: "primary" };
|
||||
}
|
||||
if (name.includes("Voice")) return { page: "voice", role: "primary" };
|
||||
if (name.includes("Suggestion"))
|
||||
return { page: "suggestions", role: "primary" };
|
||||
if (name.includes("RuntimeAdapter"))
|
||||
return { page: "runtime", role: "primary" };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (section === "context-providers") {
|
||||
if (name === "AssistantRuntimeProvider") {
|
||||
return { page: "assistant-runtime-provider", role: "primary" };
|
||||
}
|
||||
if (name === "AuiProvider") {
|
||||
return { page: "assistant-runtime-provider", role: "related" };
|
||||
}
|
||||
if (name.includes("Frame"))
|
||||
return { page: "assistant-frame-provider", role: "primary" };
|
||||
if (name.includes("ModelContext")) {
|
||||
return { page: "model-context-provider", role: "primary" };
|
||||
}
|
||||
return { page: "scoped-providers", role: "primary" };
|
||||
}
|
||||
|
||||
if (section === "runtimes") {
|
||||
if (name === "PartState" || name === "EnrichedPartState") {
|
||||
return { page: "message-part-runtime", role: "primary" };
|
||||
}
|
||||
if (name.includes("Assistant"))
|
||||
return { page: "assistant-runtime", role: "primary" };
|
||||
// Order matters: ThreadListItem includes ThreadList, and ThreadList includes Thread.
|
||||
if (name.includes("ThreadListItem")) {
|
||||
return { page: "thread-list-item-runtime", role: "primary" };
|
||||
}
|
||||
if (name.includes("ThreadList")) {
|
||||
return { page: "thread-list-runtime", role: "primary" };
|
||||
}
|
||||
if (name.includes("Thread"))
|
||||
return { page: "thread-runtime", role: "primary" };
|
||||
if (name.includes("Composer"))
|
||||
return { page: "composer-runtime", role: "primary" };
|
||||
// Order matters: MessagePart includes Message.
|
||||
if (name.includes("MessagePart")) {
|
||||
return { page: "message-part-runtime", role: "primary" };
|
||||
}
|
||||
if (name.includes("Message"))
|
||||
return { page: "message-runtime", role: "primary" };
|
||||
if (name.includes("Attachment")) {
|
||||
return { page: "attachment-runtime", role: "primary" };
|
||||
}
|
||||
if (name.includes("Voice")) return { page: "voice-state", role: "primary" };
|
||||
if (name.includes("Dictation"))
|
||||
return { page: "dictation-state", role: "primary" };
|
||||
if (name.includes("Queue")) return { page: "queue-state", role: "primary" };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import {
|
||||
Node,
|
||||
type ExportDeclaration,
|
||||
type ExportSpecifier,
|
||||
type Node as TsNode,
|
||||
} from "ts-morph";
|
||||
import * as path from "node:path";
|
||||
import { REACT_GENERATIVE_UI_INDEX, REACT_INDEX, REPO_ROOT } from "./paths.mts";
|
||||
import {
|
||||
GENERATIVE_UI_PACKAGE_EXPORTS,
|
||||
classifyExport,
|
||||
relatedOrSupportingRole,
|
||||
} from "./classify.mts";
|
||||
import {
|
||||
chooseDeclaration,
|
||||
extractJsDoc,
|
||||
extractSignature,
|
||||
getAllExportedNames,
|
||||
getProject,
|
||||
renderJsDocLinks,
|
||||
type JsDocRenderOptions,
|
||||
} from "./extract.mts";
|
||||
|
||||
export type ApiSection =
|
||||
| "tools"
|
||||
| "model-context"
|
||||
| "transport"
|
||||
| "external-store"
|
||||
| "voice"
|
||||
| "generative-ui"
|
||||
| "primitives"
|
||||
| "hooks"
|
||||
| "adapters"
|
||||
| "runtimes"
|
||||
| "context-providers"
|
||||
| "integrations"
|
||||
| "utilities";
|
||||
|
||||
export type ExportKind =
|
||||
| "class"
|
||||
| "component"
|
||||
| "function"
|
||||
| "interface"
|
||||
| "namespace"
|
||||
| "type"
|
||||
| "value";
|
||||
|
||||
export type ExportInfo = {
|
||||
name: string;
|
||||
section: ApiSection;
|
||||
kind: ExportKind;
|
||||
page: string;
|
||||
pageRole: "primary" | "related" | "supporting-type";
|
||||
sourcePath?: string | undefined;
|
||||
jsDoc?: string | undefined;
|
||||
jsDocExamples?: string[] | undefined;
|
||||
deprecated?: string | undefined;
|
||||
signature?: string | undefined;
|
||||
classificationRule: string;
|
||||
classificationConfidence: "strong" | "medium" | "fallback";
|
||||
classificationReason: string;
|
||||
jsDocRenderOptions?: JsDocRenderOptions;
|
||||
};
|
||||
|
||||
export const SECTION_ORDER = [
|
||||
"tools",
|
||||
"model-context",
|
||||
"transport",
|
||||
"external-store",
|
||||
"voice",
|
||||
"generative-ui",
|
||||
"primitives",
|
||||
"hooks",
|
||||
"adapters",
|
||||
"runtimes",
|
||||
"context-providers",
|
||||
"integrations",
|
||||
"utilities",
|
||||
] as const satisfies readonly ApiSection[];
|
||||
|
||||
export const REACT_API_SECTIONS = SECTION_ORDER.filter(
|
||||
(section) => section !== "integrations",
|
||||
);
|
||||
|
||||
const IGNORED_EXPORTS = new Set([
|
||||
"AssistantEventCallback",
|
||||
"AssistantEventName",
|
||||
"AssistantEventPayload",
|
||||
"AssistantEventScope",
|
||||
"AssistantEventSelector",
|
||||
"AssistantState",
|
||||
"DevToolsProviderApi",
|
||||
"FrameMessageType",
|
||||
"LanguageModelV1CallSettings",
|
||||
"Unstable_UseMentionAdapterOptions",
|
||||
"Unstable_UseSlashCommandAdapterOptions",
|
||||
]);
|
||||
|
||||
type ExportEntry = {
|
||||
name: string;
|
||||
exportNode: ExportDeclaration;
|
||||
specifier?: ExportSpecifier;
|
||||
};
|
||||
|
||||
function getExportEntries(decl: ExportDeclaration): ExportEntry[] {
|
||||
const namespaceExport = decl.getNamespaceExport();
|
||||
if (namespaceExport) {
|
||||
return [{ name: namespaceExport.getName(), exportNode: decl }];
|
||||
}
|
||||
return decl.getNamedExports().map((specifier) => ({
|
||||
name: specifier.getAliasNode()?.getText() ?? specifier.getName(),
|
||||
exportNode: decl,
|
||||
specifier,
|
||||
}));
|
||||
}
|
||||
|
||||
function classifyKind(node: TsNode | undefined, name: string): ExportKind {
|
||||
if (!node) return "value";
|
||||
if (Node.isClassDeclaration(node)) return "class";
|
||||
if (name.endsWith("Provider")) return "component";
|
||||
if (Node.isInterfaceDeclaration(node)) return "interface";
|
||||
if (Node.isTypeAliasDeclaration(node)) return "type";
|
||||
if (Node.isFunctionDeclaration(node)) return "function";
|
||||
if (Node.isModuleDeclaration(node)) return "namespace";
|
||||
if (Node.isVariableDeclaration(node)) {
|
||||
if (/^[A-Z]/.test(name)) return "component";
|
||||
if (/^(unstable_)?use[A-Z]/.test(name)) return "function";
|
||||
}
|
||||
if (Node.isBindingElement(node) && /^(unstable_)?use[A-Z]/.test(name)) {
|
||||
return "function";
|
||||
}
|
||||
return "value";
|
||||
}
|
||||
|
||||
function resolveDeclaration(entry: ExportEntry): TsNode | undefined {
|
||||
const symbols = [
|
||||
entry.specifier?.getSymbol()?.getAliasedSymbol(),
|
||||
entry.specifier?.getSymbol(),
|
||||
entry.exportNode.getNamespaceExport()?.getSymbol()?.getAliasedSymbol(),
|
||||
entry.exportNode.getNamespaceExport()?.getSymbol(),
|
||||
];
|
||||
for (const symbol of symbols) {
|
||||
const declaration = chooseDeclaration(symbol?.getDeclarations() ?? []);
|
||||
if (declaration) return declaration;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getLeadingCommentText(node: TsNode): string {
|
||||
return node
|
||||
.getLeadingCommentRanges()
|
||||
.map((range) => range.getText())
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function exportEntryDeprecated(entry: ExportEntry): string | undefined {
|
||||
const comments = [
|
||||
entry.specifier ? getLeadingCommentText(entry.specifier) : "",
|
||||
getLeadingCommentText(entry.exportNode),
|
||||
].join("\n");
|
||||
if (!comments.includes("@deprecated")) return undefined;
|
||||
return comments.match(/@deprecated\s+([^*\n]+)/)?.[1]?.trim() || "true";
|
||||
}
|
||||
|
||||
function relativeToRepo(filePath: string | undefined): string | undefined {
|
||||
if (!filePath) return undefined;
|
||||
return path.relative(REPO_ROOT, filePath);
|
||||
}
|
||||
|
||||
type DiscoveredExportInput = {
|
||||
name: string;
|
||||
resolved: TsNode | undefined;
|
||||
deprecated?: string | undefined;
|
||||
};
|
||||
|
||||
type ClassifiedExportInput = DiscoveredExportInput & {
|
||||
sourcePath?: string | undefined;
|
||||
kind: ExportKind;
|
||||
placement: ReturnType<typeof classifyExport>;
|
||||
};
|
||||
|
||||
type ApiReferenceLinkItem = Pick<ExportInfo, "name" | "section" | "page">;
|
||||
|
||||
const MANUAL_API_REFERENCE_LINKS = new Map([
|
||||
[
|
||||
"AISDKToolkit",
|
||||
"/docs/api-reference/integrations/react-ai-sdk#aisdktoolkit",
|
||||
],
|
||||
[
|
||||
"JSONGenerativeUI.present",
|
||||
"/docs/api-reference/generative-ui/json-generative-ui#jsongenerativeui",
|
||||
],
|
||||
[
|
||||
"JSONGenerativeUI.promptUser",
|
||||
"/docs/api-reference/generative-ui/json-generative-ui#jsongenerativeui",
|
||||
],
|
||||
[
|
||||
"AssistantState",
|
||||
"/docs/api-reference/primitives/assistant-if#assistantstate",
|
||||
],
|
||||
]);
|
||||
|
||||
function collectExportInputs(entryPath: string): DiscoveredExportInput[] {
|
||||
const project = getProject();
|
||||
const sourceFile =
|
||||
project.getSourceFile(entryPath) ?? project.addSourceFileAtPath(entryPath);
|
||||
const seen = new Set<string>();
|
||||
const exports: DiscoveredExportInput[] = [];
|
||||
|
||||
const addExport = (input: DiscoveredExportInput) => {
|
||||
if (seen.has(input.name) || IGNORED_EXPORTS.has(input.name)) return;
|
||||
seen.add(input.name);
|
||||
exports.push(input);
|
||||
};
|
||||
|
||||
for (const decl of sourceFile.getExportDeclarations()) {
|
||||
for (const entry of getExportEntries(decl)) {
|
||||
addExport({
|
||||
name: entry.name,
|
||||
resolved: resolveDeclaration(entry),
|
||||
deprecated: exportEntryDeprecated(entry),
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [name, declarations] of sourceFile.getExportedDeclarations()) {
|
||||
addExport({ name, resolved: chooseDeclaration(declarations) });
|
||||
}
|
||||
return exports.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function headingAnchor(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{Letter}\p{Number}\s_-]/gu, "")
|
||||
.replace(/\s+/g, "-");
|
||||
}
|
||||
|
||||
function apiReferenceHref(item: Pick<ExportInfo, "section" | "page" | "name">) {
|
||||
return `/docs/api-reference/${item.section}/${item.page}#${headingAnchor(item.name)}`;
|
||||
}
|
||||
|
||||
function cleanLinkTarget(target: string): string {
|
||||
return target.trim().replace(/^`|`$/g, "").replace(/\(\)$/, "");
|
||||
}
|
||||
|
||||
function createApiReferenceLinkResolver(items: ApiReferenceLinkItem[]) {
|
||||
const itemByName = new Map(items.map((item) => [item.name, item]));
|
||||
return (target: string): string | undefined => {
|
||||
const name = cleanLinkTarget(target);
|
||||
const manualHref = MANUAL_API_REFERENCE_LINKS.get(name);
|
||||
if (manualHref) return manualHref;
|
||||
|
||||
const item = itemByName.get(name);
|
||||
return item ? apiReferenceHref(item) : undefined;
|
||||
};
|
||||
}
|
||||
|
||||
/** Predicate over every discovered export name — including supporting types
|
||||
* that get no standalone anchor. Lets the JSDoc renderer tell a legitimately
|
||||
* anchorless reference apart from a broken link (see JsDocRenderOptions).
|
||||
* Closes over the caller's (already cached) set rather than copying it. */
|
||||
function createKnownExportPredicate(
|
||||
known: ReadonlySet<string>,
|
||||
): (target: string) => boolean {
|
||||
return (target: string): boolean => known.has(cleanLinkTarget(target));
|
||||
}
|
||||
|
||||
function isBetterDonor(
|
||||
candidate: ClassifiedExportInput,
|
||||
current: ClassifiedExportInput,
|
||||
): boolean {
|
||||
const primary = (item: ClassifiedExportInput) =>
|
||||
item.placement.role === "primary" ? 0 : 1;
|
||||
if (primary(candidate) !== primary(current)) {
|
||||
return primary(candidate) < primary(current);
|
||||
}
|
||||
const strong = (item: ClassifiedExportInput) =>
|
||||
item.placement.confidence === "strong" ? 0 : 1;
|
||||
if (strong(candidate) !== strong(current)) {
|
||||
return strong(candidate) < strong(current);
|
||||
}
|
||||
return candidate.name.localeCompare(current.name) < 0;
|
||||
}
|
||||
|
||||
function classifyExportInputs(
|
||||
inputs: DiscoveredExportInput[],
|
||||
): ClassifiedExportInput[] {
|
||||
const classified = inputs.map((input) => {
|
||||
const sourcePath = relativeToRepo(
|
||||
input.resolved?.getSourceFile().getFilePath(),
|
||||
);
|
||||
const kind = classifyKind(input.resolved, input.name);
|
||||
const placement = classifyExport({ name: input.name, kind, sourcePath });
|
||||
return { ...input, sourcePath, kind, placement };
|
||||
});
|
||||
|
||||
// Unplaceable exports inherit a confidently-classified file-mate's location.
|
||||
const donorBySource = new Map<string, ClassifiedExportInput>();
|
||||
for (const item of classified) {
|
||||
if (!item.sourcePath || item.placement.confidence === "fallback") continue;
|
||||
const current = donorBySource.get(item.sourcePath);
|
||||
if (!current || isBetterDonor(item, current)) {
|
||||
donorBySource.set(item.sourcePath, item);
|
||||
}
|
||||
}
|
||||
|
||||
return classified.map((item) => {
|
||||
if (item.placement.confidence !== "fallback" || !item.sourcePath) {
|
||||
return item;
|
||||
}
|
||||
const donor = donorBySource.get(item.sourcePath);
|
||||
if (!donor) return item;
|
||||
return {
|
||||
...item,
|
||||
placement: {
|
||||
...donor.placement,
|
||||
role: relatedOrSupportingRole(item.kind),
|
||||
rule: "fallback:co-location",
|
||||
confidence: "medium",
|
||||
reason: `inherited from co-located ${donor.name}`,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function linkItemsFor(inputs: ClassifiedExportInput[]): ApiReferenceLinkItem[] {
|
||||
return inputs
|
||||
.filter((item) => item.placement.role !== "supporting-type")
|
||||
.map((item) => ({
|
||||
name: item.name,
|
||||
section: item.placement.section,
|
||||
page: item.placement.page,
|
||||
}));
|
||||
}
|
||||
|
||||
let reactApiInputs: ClassifiedExportInput[] | undefined;
|
||||
let reactGenerativeUIApiInputs: ClassifiedExportInput[] | undefined;
|
||||
let mainApiInputs: ClassifiedExportInput[] | undefined;
|
||||
let mainApiLinkItems: ApiReferenceLinkItem[] | undefined;
|
||||
let reactApiRenderOptions: JsDocRenderOptions | undefined;
|
||||
|
||||
function getReactApiInputs(): ClassifiedExportInput[] {
|
||||
reactApiInputs ??= classifyExportInputs(collectExportInputs(REACT_INDEX));
|
||||
return reactApiInputs;
|
||||
}
|
||||
|
||||
function getReactGenerativeUIApiInputs(): ClassifiedExportInput[] {
|
||||
reactGenerativeUIApiInputs ??= classifyExportInputs(
|
||||
collectExportInputs(REACT_GENERATIVE_UI_INDEX),
|
||||
).filter((item) => GENERATIVE_UI_PACKAGE_EXPORTS.has(item.name));
|
||||
return reactGenerativeUIApiInputs;
|
||||
}
|
||||
|
||||
function getMainApiInputs(): ClassifiedExportInput[] {
|
||||
mainApiInputs ??= [
|
||||
...getReactApiInputs(),
|
||||
...getReactGenerativeUIApiInputs(),
|
||||
];
|
||||
return mainApiInputs;
|
||||
}
|
||||
|
||||
function getMainApiLinkItems(): ApiReferenceLinkItem[] {
|
||||
mainApiLinkItems ??= linkItemsFor(getMainApiInputs());
|
||||
return mainApiLinkItems;
|
||||
}
|
||||
|
||||
/** Bundled JSDoc render options (link resolver + known-export predicate) for
|
||||
* the react public API, lazily built once. Reused by the primitive-docs
|
||||
* generator so primitive prop JSDoc links resolve the same way the
|
||||
* api-reference pass resolves them. */
|
||||
export function getReactApiRenderOptions(): JsDocRenderOptions {
|
||||
reactApiRenderOptions ??= {
|
||||
linkResolver: createApiReferenceLinkResolver(getMainApiLinkItems()),
|
||||
isKnownExport: createKnownExportPredicate(getAllExportedNames()),
|
||||
};
|
||||
return reactApiRenderOptions;
|
||||
}
|
||||
|
||||
function buildExportInfo(
|
||||
{
|
||||
name,
|
||||
resolved,
|
||||
deprecated,
|
||||
sourcePath,
|
||||
kind,
|
||||
placement,
|
||||
}: ClassifiedExportInput,
|
||||
renderOptions: JsDocRenderOptions,
|
||||
overrides: Partial<
|
||||
Pick<
|
||||
ExportInfo,
|
||||
| "section"
|
||||
| "page"
|
||||
| "pageRole"
|
||||
| "classificationRule"
|
||||
| "classificationConfidence"
|
||||
| "classificationReason"
|
||||
>
|
||||
> = {},
|
||||
): ExportInfo {
|
||||
const docs = extractJsDoc(resolved, renderOptions);
|
||||
const signature = extractSignature(resolved, name);
|
||||
const resolvedDeprecated = deprecated
|
||||
? renderJsDocLinks(deprecated, `${name} export @deprecated`, renderOptions)
|
||||
: docs.deprecated;
|
||||
return {
|
||||
name,
|
||||
section: overrides.section ?? placement.section,
|
||||
kind,
|
||||
page: overrides.page ?? placement.page,
|
||||
pageRole: overrides.pageRole ?? placement.role,
|
||||
sourcePath,
|
||||
jsDoc: docs.jsDoc,
|
||||
jsDocExamples: docs.examples,
|
||||
deprecated: resolvedDeprecated,
|
||||
signature,
|
||||
classificationRule: overrides.classificationRule ?? placement.rule,
|
||||
classificationConfidence:
|
||||
overrides.classificationConfidence ?? placement.confidence,
|
||||
classificationReason: overrides.classificationReason ?? placement.reason,
|
||||
jsDocRenderOptions: renderOptions,
|
||||
};
|
||||
}
|
||||
|
||||
export function discoverExports(): ExportInfo[] {
|
||||
const inputs = getMainApiInputs();
|
||||
const renderOptions = getReactApiRenderOptions();
|
||||
return inputs.map((input) => buildExportInfo(input, renderOptions));
|
||||
}
|
||||
|
||||
export function discoverIntegrationExports(
|
||||
entryPath: string,
|
||||
page: string,
|
||||
): ExportInfo[] {
|
||||
const inputs = classifyExportInputs(collectExportInputs(entryPath)).filter(
|
||||
({ kind }) => kind !== "interface" && kind !== "type",
|
||||
);
|
||||
// Links resolve to where these exports render — their integration page — not
|
||||
// to the raw placement buildExportInfo overrides below.
|
||||
const renderOptions: JsDocRenderOptions = {
|
||||
linkResolver: createApiReferenceLinkResolver([
|
||||
...getMainApiLinkItems(),
|
||||
// inputs already excludes interface/type kinds (the only ones that ever
|
||||
// get a "supporting-type" role), so every entry here renders on the page.
|
||||
...inputs.map((item) => ({
|
||||
name: item.name,
|
||||
section: "integrations" as const,
|
||||
page,
|
||||
})),
|
||||
]),
|
||||
isKnownExport: createKnownExportPredicate(getAllExportedNames()),
|
||||
};
|
||||
|
||||
return inputs.map((input) =>
|
||||
buildExportInfo(input, renderOptions, {
|
||||
section: "integrations",
|
||||
page,
|
||||
pageRole: "primary",
|
||||
classificationRule: "integration:package",
|
||||
classificationConfidence: "strong",
|
||||
classificationReason: "package-level integration export",
|
||||
}),
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const REPO_ROOT = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../..",
|
||||
);
|
||||
|
||||
export const DOCS_ROOT = path.join(REPO_ROOT, "apps/docs");
|
||||
|
||||
export const REACT_PKG = path.join(REPO_ROOT, "packages/react/src");
|
||||
export const REACT_GENERATIVE_UI_PKG = path.join(
|
||||
REPO_ROOT,
|
||||
"packages/react-generative-ui/src",
|
||||
);
|
||||
export const CORE_PKG = path.join(REPO_ROOT, "packages/core/src");
|
||||
export const PRIMITIVES_DIR = path.join(REACT_PKG, "primitives");
|
||||
export const REACT_INDEX = path.join(REACT_PKG, "index.ts");
|
||||
export const REACT_GENERATIVE_UI_INDEX = path.join(
|
||||
REACT_GENERATIVE_UI_PKG,
|
||||
"index.ts",
|
||||
);
|
||||
|
||||
export const TYPE_DOCS_INPUT = path.join(
|
||||
DOCS_ROOT,
|
||||
"content/types-to-generate/typeDocs.ts",
|
||||
);
|
||||
export const TYPE_DOCS_OUTPUT = path.join(DOCS_ROOT, "generated/typeDocs.ts");
|
||||
export const INTEGRATION_TYPE_DOCS_OUTPUT = path.join(
|
||||
DOCS_ROOT,
|
||||
"generated/integrationTypeDocs.ts",
|
||||
);
|
||||
export const PRIMITIVE_DOCS_OUTPUT = path.join(
|
||||
DOCS_ROOT,
|
||||
"generated/primitiveDocs.ts",
|
||||
);
|
||||
|
||||
export const API_REFERENCE_DIR = path.join(
|
||||
DOCS_ROOT,
|
||||
"content/docs/(reference)/api-reference",
|
||||
);
|
||||
|
||||
export const INTEGRATION_PACKAGES = [
|
||||
{
|
||||
slug: "react-ai-sdk",
|
||||
packageName: "@assistant-ui/react-ai-sdk",
|
||||
entry: path.join(REPO_ROOT, "packages/react-ai-sdk/src/index.ts"),
|
||||
},
|
||||
{
|
||||
slug: "react-data-stream",
|
||||
packageName: "@assistant-ui/react-data-stream",
|
||||
entry: path.join(REPO_ROOT, "packages/react-data-stream/src/index.ts"),
|
||||
},
|
||||
{
|
||||
slug: "cloud-ai-sdk",
|
||||
packageName: "@assistant-ui/cloud-ai-sdk",
|
||||
entry: path.join(REPO_ROOT, "packages/cloud-ai-sdk/src/index.ts"),
|
||||
},
|
||||
{
|
||||
slug: "eve",
|
||||
packageName: "@assistant-ui/eve",
|
||||
entry: path.join(REPO_ROOT, "packages/eve/src/index.ts"),
|
||||
},
|
||||
] as const;
|
||||
@@ -0,0 +1,211 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { PRIMITIVE_DOCS_OUTPUT } from "./paths.mts";
|
||||
import { getReactApiRenderOptions } from "./discover.mts";
|
||||
import type { InheritedFrom, PropModel } from "./extract.mts";
|
||||
import {
|
||||
extractPrimitiveParts,
|
||||
type PrimitivePartModel,
|
||||
} from "./primitive-extract.mts";
|
||||
|
||||
// ── Projection: PrimitivePartModel[] \u2192 grouped doc shape ──────────────────
|
||||
|
||||
type RenderedProp = {
|
||||
name: string;
|
||||
type?: string;
|
||||
description?: string;
|
||||
default?: string;
|
||||
required?: boolean;
|
||||
deprecated?: string;
|
||||
children?: Array<{ type?: string; parameters: RenderedProp[] }>;
|
||||
};
|
||||
|
||||
type RenderedPart = {
|
||||
element?: string;
|
||||
description?: string;
|
||||
deprecated?: string;
|
||||
props: RenderedProp[];
|
||||
};
|
||||
|
||||
type GroupedPrimitives = Record<string, Record<string, RenderedPart>>;
|
||||
|
||||
const PRIMITIVE_FILTER: ReadonlySet<InheritedFrom> = new Set([
|
||||
"react",
|
||||
"csstype",
|
||||
"react-textarea-autosize",
|
||||
"radix",
|
||||
]);
|
||||
|
||||
function shouldDropForPrimitive(prop: PropModel): boolean {
|
||||
if (prop.name.startsWith("__")) return true;
|
||||
// `tw` is a polluting global JSX prop from `@vercel/og` types pulled in by
|
||||
// the Next.js tsconfig. Legacy primitive-docs ran with
|
||||
// `skipAddingFilesFromTsConfig: true` so it never saw this prop; we drop
|
||||
// it by name here to preserve that behaviour.
|
||||
if (prop.name === "tw") return true;
|
||||
return (
|
||||
prop.inheritedFrom !== undefined && PRIMITIVE_FILTER.has(prop.inheritedFrom)
|
||||
);
|
||||
}
|
||||
|
||||
/** Truncate very long inline object types at a token boundary. Mirrors the
|
||||
* legacy primitive-docs cleanTypeText behaviour exactly. */
|
||||
function presentPrimitiveType(rawType: string | undefined): string | undefined {
|
||||
if (!rawType) return undefined;
|
||||
if (rawType.length <= 120) return rawType;
|
||||
return rawType.replace(/\{[^{}]{100,}\}/g, (match) => {
|
||||
let cutoff = 80;
|
||||
const semicolonIdx = match.lastIndexOf(";", cutoff);
|
||||
const commaIdx = match.lastIndexOf(",", cutoff);
|
||||
const breakIdx = Math.max(semicolonIdx, commaIdx);
|
||||
if (breakIdx > 20) cutoff = breakIdx + 1;
|
||||
return `${match.substring(0, cutoff)} ... }`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Project one level of children. Legacy primitive-docs only expanded
|
||||
* children one level deep (no recursion), and emitted children for the
|
||||
* `components` prop without a wrapping `type` field. */
|
||||
function projectChildrenShallow(
|
||||
children: PropModel["children"],
|
||||
parentPropName: string,
|
||||
): RenderedProp["children"] | undefined {
|
||||
if (!children) return undefined;
|
||||
const omitType = parentPropName === "components";
|
||||
const out = children
|
||||
.map(({ typeName, props }) => {
|
||||
const parameters = props
|
||||
.filter((child) => !shouldDropForPrimitive(child))
|
||||
.map((child) => projectProp(child, false));
|
||||
if (parameters.length === 0) return undefined;
|
||||
return omitType ? { parameters } : { type: typeName, parameters };
|
||||
})
|
||||
.filter((entry): entry is { type?: string; parameters: RenderedProp[] } =>
|
||||
Boolean(entry),
|
||||
);
|
||||
return out.length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
function isInlineObjectTypeText(typeText: string | undefined): boolean {
|
||||
if (!typeText) return false;
|
||||
const text = typeText.trim();
|
||||
return text.startsWith("{") || text.startsWith("| {");
|
||||
}
|
||||
|
||||
function projectProp(prop: PropModel, allowChildren = true): RenderedProp {
|
||||
// Legacy primitive-docs only expanded children when:
|
||||
// - prop is named `components` AND its type text contained `{`, OR
|
||||
// - the prop's type-node was an inline object literal.
|
||||
// Use rawType as a proxy for both checks.
|
||||
const rawText = prop.rawType ?? "";
|
||||
const shouldExpand =
|
||||
allowChildren &&
|
||||
((prop.name === "components" && rawText.includes("{")) ||
|
||||
isInlineObjectTypeText(prop.rawType));
|
||||
const children = shouldExpand
|
||||
? projectChildrenShallow(prop.children, prop.name)
|
||||
: undefined;
|
||||
const type = presentPrimitiveType(prop.rawType);
|
||||
const out: RenderedProp = { name: prop.name };
|
||||
if (type !== undefined) out.type = type;
|
||||
if (prop.description) out.description = prop.description;
|
||||
if (prop.default) out.default = prop.default;
|
||||
if (prop.required) out.required = true;
|
||||
if (prop.deprecated) out.deprecated = prop.deprecated;
|
||||
if (children) out.children = children;
|
||||
return out;
|
||||
}
|
||||
|
||||
function projectPart(part: PrimitivePartModel): RenderedPart {
|
||||
let props: RenderedProp[];
|
||||
|
||||
if (part.isActionButton) {
|
||||
// Legacy primitive-docs only documented hook-specific params for
|
||||
// ActionButton parts. Both `render` (locally declared in WithRenderProp)
|
||||
// and the rest of PrimitiveButtonProps are excluded here; `asChild` is
|
||||
// re-injected below as a bare row. Also strip trailing `| undefined`
|
||||
// from hook param types to match legacy ActionButton output.
|
||||
props = part.props
|
||||
.filter((p) => !shouldDropForPrimitive(p))
|
||||
.filter((p) => p.name !== "render" && p.name !== "asChild")
|
||||
// Legacy parity: this projection historically gated child expansion on
|
||||
// the array index, so the first prop never expands its children.
|
||||
.map((p, i) => projectProp(p, i !== 0))
|
||||
.map((p) =>
|
||||
p.type ? { ...p, type: p.type.replace(/\s*\|\s*undefined$/, "") } : p,
|
||||
);
|
||||
props = [{ name: "asChild" }, ...props];
|
||||
} else {
|
||||
props = part.props
|
||||
.filter((p) => !shouldDropForPrimitive(p))
|
||||
// Legacy parity: child expansion was historically gated on the array
|
||||
// index, so the first prop never expands its children.
|
||||
.map((p, i) => projectProp(p, i !== 0));
|
||||
// Inject a bare asChild when the part supports it and the projection
|
||||
// didn't already carry one.
|
||||
if (part.supportsAsChild && !props.some((p) => p.name === "asChild")) {
|
||||
props = [{ name: "asChild" }, ...props];
|
||||
}
|
||||
}
|
||||
|
||||
const out: RenderedPart = { props };
|
||||
if (part.element) out.element = part.element;
|
||||
if (part.description) out.description = part.description;
|
||||
if (part.deprecated) out.deprecated = part.deprecated;
|
||||
return out;
|
||||
}
|
||||
|
||||
function projectToPrimitiveDocs(
|
||||
parts: PrimitivePartModel[],
|
||||
): GroupedPrimitives {
|
||||
const result: GroupedPrimitives = {};
|
||||
// First-wins per (primitiveName, localName) so that `Content` aliased to
|
||||
// `Parts` doesn't emit a duplicate entry. Order matches barrel order from
|
||||
// the underlying source file.
|
||||
const seen = new Map<string, Set<string>>();
|
||||
for (const part of parts) {
|
||||
const seenLocals = seen.get(part.primitiveName) ?? new Set();
|
||||
if (seenLocals.has(part.localName)) continue;
|
||||
seenLocals.add(part.localName);
|
||||
seen.set(part.primitiveName, seenLocals);
|
||||
let primitive = result[part.primitiveName];
|
||||
if (!primitive) {
|
||||
primitive = {};
|
||||
result[part.primitiveName] = primitive;
|
||||
}
|
||||
primitive[part.partName] = projectPart(part);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function serialize(grouped: GroupedPrimitives): string {
|
||||
const lines: string[] = [
|
||||
"// AUTO-GENERATED by scripts/generate-type-docs.mts",
|
||||
"// Do not edit manually.",
|
||||
"",
|
||||
];
|
||||
for (const [name, parts] of Object.entries(grouped)) {
|
||||
lines.push(`export const ${name} = ${JSON.stringify(parts, null, 2)};\n`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function generatePrimitiveDocs() {
|
||||
console.log("Generating primitive docs...");
|
||||
const parts = extractPrimitiveParts(getReactApiRenderOptions());
|
||||
const grouped = projectToPrimitiveDocs(parts);
|
||||
const output = serialize(grouped);
|
||||
|
||||
fs.mkdirSync(path.dirname(PRIMITIVE_DOCS_OUTPUT), { recursive: true });
|
||||
fs.writeFileSync(PRIMITIVE_DOCS_OUTPUT, output);
|
||||
|
||||
const totalParts = Object.values(grouped).reduce(
|
||||
(sum, p) => sum + Object.keys(p).length,
|
||||
0,
|
||||
);
|
||||
console.log(
|
||||
`Generated docs for ${Object.keys(grouped).length} primitives with ${totalParts} parts \u2192 ${PRIMITIVE_DOCS_OUTPUT}`,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,305 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
INTEGRATION_PACKAGES,
|
||||
INTEGRATION_TYPE_DOCS_OUTPUT,
|
||||
REPO_ROOT,
|
||||
TYPE_DOCS_INPUT,
|
||||
TYPE_DOCS_OUTPUT,
|
||||
} from "./paths.mts";
|
||||
import { type ExportInfo, discoverIntegrationExports } from "./discover.mts";
|
||||
import {
|
||||
extractExportShape,
|
||||
extractSupportingTypeShapes,
|
||||
isPrimitiveOnlyType,
|
||||
type ExtractedShape,
|
||||
type InheritedFrom,
|
||||
type PropModel,
|
||||
} from "./extract.mts";
|
||||
import {
|
||||
extractPrimitivePartsFor,
|
||||
type PrimitivePartModel,
|
||||
} from "./primitive-extract.mts";
|
||||
|
||||
// ── TypeDoc presentation shape ─────────────────────────────────────────────
|
||||
|
||||
export type TypeDocParameter = {
|
||||
name: string;
|
||||
type?: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
default?: string;
|
||||
deprecated?: string;
|
||||
children?: { type?: string; parameters: TypeDocParameter[] }[];
|
||||
};
|
||||
|
||||
export type TypeDoc = {
|
||||
type?: string;
|
||||
parameters: TypeDocParameter[];
|
||||
};
|
||||
|
||||
export type TypeDocBindings = Map<string, string>;
|
||||
|
||||
// ── Projection: PropModel → TypeDocParameter (API reference) ───────────────
|
||||
|
||||
// Match legacy api-surface filter exactly. Radix is intentionally NOT
|
||||
// filtered here — PrimitiveButtonProps members like `asChild`/`render` come
|
||||
// from @radix-ui/react-primitive and need to surface in api-ref typeDocs for
|
||||
// ActionButton-style primitive parts. The primitive-docs projection still
|
||||
// filters radix (and inserts a bare `asChild` row).
|
||||
const API_REF_FILTER: ReadonlySet<InheritedFrom> = new Set([
|
||||
"react",
|
||||
"csstype",
|
||||
"react-textarea-autosize",
|
||||
"tw",
|
||||
]);
|
||||
|
||||
function shouldDropForApiRef(prop: PropModel): boolean {
|
||||
// Always show asChild even if its declaration site is third-party.
|
||||
if (prop.name === "asChild") return false;
|
||||
// Drop the legacy "tw" pseudo-prop by name (used to live on every component
|
||||
// before tailwind-variants was internalized).
|
||||
if (prop.name === "tw") return true;
|
||||
return (
|
||||
prop.inheritedFrom !== undefined && API_REF_FILTER.has(prop.inheritedFrom)
|
||||
);
|
||||
}
|
||||
|
||||
function isInlineObjectTypeText(typeText: string): boolean {
|
||||
const text = typeText.trim();
|
||||
return text.startsWith("{") || text.startsWith("| {");
|
||||
}
|
||||
|
||||
function projectChildren(
|
||||
children: PropModel["children"],
|
||||
): TypeDocParameter["children"] | undefined {
|
||||
if (!children || children.length === 0) return undefined;
|
||||
const out = children
|
||||
.map(({ typeName, props }) => {
|
||||
const parameters = props
|
||||
.filter((child) => !shouldDropForApiRef(child))
|
||||
.map(projectPropToTypeDoc);
|
||||
return parameters.length > 0 ? { type: typeName, parameters } : undefined;
|
||||
})
|
||||
.filter(
|
||||
(entry): entry is { type: string; parameters: TypeDocParameter[] } =>
|
||||
Boolean(entry),
|
||||
);
|
||||
return out.length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
function projectPropToTypeDoc(prop: PropModel): TypeDocParameter {
|
||||
const children = projectChildren(prop.children);
|
||||
// Api-ref consumes `declaredType` (authored type-node where available,
|
||||
// falls back to resolved type minus `| undefined`). When children are
|
||||
// present and the type text is an inline object literal, the literal is
|
||||
// redundant — replace with the child type's name.
|
||||
const baseType = prop.declaredType ?? prop.rawType;
|
||||
const typeText =
|
||||
children && baseType && isInlineObjectTypeText(baseType)
|
||||
? (children[0]?.type ?? baseType)
|
||||
: baseType;
|
||||
const out: TypeDocParameter = { name: prop.name };
|
||||
if (typeText) out.type = typeText;
|
||||
if (prop.required !== undefined) out.required = prop.required;
|
||||
// Preserve empty descriptions (class members are emitted with `""`).
|
||||
if (prop.description !== undefined) out.description = prop.description;
|
||||
if (prop.default) out.default = prop.default;
|
||||
if (prop.deprecated) out.deprecated = prop.deprecated;
|
||||
if (children) out.children = children;
|
||||
return out;
|
||||
}
|
||||
|
||||
function shapeToTypeDoc(shape: ExtractedShape): TypeDoc | undefined {
|
||||
const parameters = shape.parameters
|
||||
.filter((prop) => !shouldDropForApiRef(prop))
|
||||
.map(projectPropToTypeDoc);
|
||||
// Match legacy: a shape whose every property is filtered as inherited
|
||||
// produces no typeDocs entry. Class shapes are exempt — they intentionally
|
||||
// emit empty member lists for completeness.
|
||||
if (parameters.length === 0 && shape.kind !== "class") return undefined;
|
||||
// For class shapes the legacy renderer used the bare type name (no " props"
|
||||
// suffix) — keep that. For component shapes it appended " props".
|
||||
const typeName =
|
||||
shape.kind === "component" ? `${shape.name} props` : shape.name;
|
||||
return { type: typeName, parameters };
|
||||
}
|
||||
|
||||
function primitivePartToTypeDoc(part: PrimitivePartModel): TypeDoc | undefined {
|
||||
let parameters = part.props
|
||||
.filter((prop) => !shouldDropForApiRef(prop))
|
||||
.map(projectPropToTypeDoc);
|
||||
|
||||
// Inject a bare asChild row when the type supports it and the projection
|
||||
// didn't already carry one. Mirrors primitive-docs behaviour.
|
||||
const hasAsChild = parameters.some((p) => p.name === "asChild");
|
||||
if (!hasAsChild && (part.supportsAsChild || part.isActionButton)) {
|
||||
parameters = [{ name: "asChild" }, ...parameters];
|
||||
}
|
||||
|
||||
// Match legacy: parts whose Props are empty (e.g. `Record<string, never>`)
|
||||
// produced no typeDocs entry. The MDX renderer falls back to the
|
||||
// primitiveDocs entry for these.
|
||||
if (parameters.length === 0) return undefined;
|
||||
|
||||
return { type: part.propsTypeName, parameters };
|
||||
}
|
||||
|
||||
// ── Building the typeDocs maps ─────────────────────────────────────────────
|
||||
|
||||
export function buildTypeDocs(exports: ExportInfo[]): {
|
||||
typeDocs: Map<string, TypeDoc>;
|
||||
integrationTypeDocs: Map<string, TypeDoc>;
|
||||
integrationsByPackage: Array<{
|
||||
slug: string;
|
||||
items: ExportInfo[];
|
||||
typeDocBindings: TypeDocBindings;
|
||||
typeDocNames: Set<string>;
|
||||
}>;
|
||||
} {
|
||||
const typeDocs = new Map<string, TypeDoc>();
|
||||
|
||||
// 1. Static input file: types declared in content/types-to-generate/typeDocs.ts.
|
||||
for (const [name, shape] of extractSupportingTypeShapes(TYPE_DOCS_INPUT)) {
|
||||
const td = shapeToTypeDoc(shape);
|
||||
if (td) typeDocs.set(name, td);
|
||||
}
|
||||
|
||||
// 2. For each export, lazily extract primitive parts (for primitive
|
||||
// sections) interleaved with per-export shape extraction. This load
|
||||
// order matters: ts-morph's intersection property iteration depends on
|
||||
// when source files are first added to the project, and legacy
|
||||
// api-surface interleaved primitive-part loading inside the export
|
||||
// loop. Mirroring that here keeps property order byte-for-byte stable.
|
||||
for (const item of exports) {
|
||||
if (item.section === "primitives") {
|
||||
for (const part of extractPrimitivePartsFor(
|
||||
item.name,
|
||||
item.jsDocRenderOptions,
|
||||
)) {
|
||||
// Skip lowercase `unstable_*` parts in api-ref typeDocs to match
|
||||
// legacy api-surface (which only matched capital `Unstable_`).
|
||||
// The primitive-docs projection still emits these.
|
||||
if (/^unstable_[A-Z]/.test(part.partName)) continue;
|
||||
const name = part.propsTypeName;
|
||||
if (typeDocs.has(name)) continue;
|
||||
const td = primitivePartToTypeDoc(part);
|
||||
if (td) typeDocs.set(name, td);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Each export → its own shape (and supporting types from the same file
|
||||
// when the primary shape can't be projected).
|
||||
for (const item of exports) {
|
||||
if (!item.sourcePath) continue;
|
||||
if (typeDocs.has(item.name)) continue;
|
||||
const shape = extractExportShape(item);
|
||||
const td = shape ? shapeToTypeDoc(shape) : undefined;
|
||||
if (td) {
|
||||
typeDocs.set(item.name, td);
|
||||
} else {
|
||||
// Either the shape couldn't be projected, or every property was
|
||||
// filtered as inherited. In both cases, fall back to absorbing other
|
||||
// supporting type aliases declared in the same file.
|
||||
const filePath = path.join(REPO_ROOT, item.sourcePath);
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
for (const [name, supportingShape] of extractSupportingTypeShapes(
|
||||
filePath,
|
||||
item.jsDocRenderOptions,
|
||||
)) {
|
||||
if (typeDocs.has(name)) continue;
|
||||
const supportingTd = shapeToTypeDoc(supportingShape);
|
||||
if (supportingTd) typeDocs.set(name, supportingTd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Integration packages: discover, project shapes (skipping primitive
|
||||
// type aliases), namespace bindings.
|
||||
const integrationTypeDocs = new Map<string, TypeDoc>();
|
||||
const integrationsByPackage: Array<{
|
||||
slug: string;
|
||||
items: ExportInfo[];
|
||||
typeDocBindings: TypeDocBindings;
|
||||
typeDocNames: Set<string>;
|
||||
}> = [];
|
||||
for (const integration of INTEGRATION_PACKAGES) {
|
||||
const items = discoverIntegrationExports(
|
||||
integration.entry,
|
||||
integration.slug,
|
||||
);
|
||||
const localTypeDocs = new Map<string, TypeDoc>();
|
||||
for (const item of items) {
|
||||
if (!item.sourcePath) continue;
|
||||
if (localTypeDocs.has(item.name)) continue;
|
||||
const filePath = path.join(REPO_ROOT, item.sourcePath);
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
if (isPrimitiveOnlyType(filePath, item.name)) continue;
|
||||
const shape = extractExportShape(item);
|
||||
const td = shape ? shapeToTypeDoc(shape) : undefined;
|
||||
if (td) localTypeDocs.set(item.name, td);
|
||||
}
|
||||
const typeDocBindings: TypeDocBindings = new Map(
|
||||
[...localTypeDocs.keys()].map((name) => [
|
||||
name,
|
||||
typeDocBindingForIntegration(integration.slug, name),
|
||||
]),
|
||||
);
|
||||
for (const [name, typeDoc] of localTypeDocs) {
|
||||
integrationTypeDocs.set(
|
||||
typeDocBindingForIntegration(integration.slug, name),
|
||||
typeDoc,
|
||||
);
|
||||
}
|
||||
integrationsByPackage.push({
|
||||
slug: integration.slug,
|
||||
items,
|
||||
typeDocBindings,
|
||||
typeDocNames: new Set(localTypeDocs.keys()),
|
||||
});
|
||||
}
|
||||
|
||||
return { typeDocs, integrationTypeDocs, integrationsByPackage };
|
||||
}
|
||||
|
||||
export function typeDocBindingForIntegration(
|
||||
slug: string,
|
||||
name: string,
|
||||
): string {
|
||||
return `${slug.replace(/-([a-z])/g, (_, char: string) => char.toUpperCase())}_${name}`;
|
||||
}
|
||||
|
||||
function writeTypeDocsFile(
|
||||
outputPath: string,
|
||||
typeDocs: Map<string, TypeDoc>,
|
||||
bindingForName: (name: string) => string,
|
||||
): void {
|
||||
const output = [
|
||||
"// AUTO-GENERATED by scripts/generate-type-docs.mts",
|
||||
"// Do not edit manually.",
|
||||
"",
|
||||
...[...typeDocs.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(
|
||||
([name, type]) =>
|
||||
`export const ${bindingForName(name)} = ${JSON.stringify(type, null, 2)};\n`,
|
||||
),
|
||||
].join("\n");
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, output);
|
||||
}
|
||||
|
||||
export function writeTypeDocs(typeDocs: Map<string, TypeDoc>): void {
|
||||
writeTypeDocsFile(TYPE_DOCS_OUTPUT, typeDocs, (name) => name);
|
||||
}
|
||||
|
||||
export function writeIntegrationTypeDocs(
|
||||
integrationTypeDocs: Map<string, TypeDoc>,
|
||||
): void {
|
||||
writeTypeDocsFile(
|
||||
INTEGRATION_TYPE_DOCS_OUTPUT,
|
||||
integrationTypeDocs,
|
||||
(name) => name,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user