e30e75b5d4
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
1187 lines
34 KiB
TypeScript
1187 lines
34 KiB
TypeScript
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import {
|
|
API_REFERENCE_DIR,
|
|
INTEGRATION_PACKAGES,
|
|
REPO_ROOT,
|
|
} from "./paths.mts";
|
|
import {
|
|
REACT_API_SECTIONS,
|
|
SECTION_ORDER,
|
|
type ApiSection,
|
|
type ExportInfo,
|
|
} from "./discover.mts";
|
|
import {
|
|
extractPrimitivePartsFor,
|
|
primitivePartTypeDocName,
|
|
readPrimitiveParts,
|
|
type PrimitivePartModel,
|
|
} from "./primitive-extract.mts";
|
|
import type { TypeDoc, TypeDocBindings } from "./type-docs.mts";
|
|
|
|
// ── MDX rendering ──────────────────────────────────────────────────────────
|
|
|
|
const GENERATED_PAGE_MARKER =
|
|
"{/* AUTO-GENERATED PAGE by scripts/generate-api-reference.mts */}";
|
|
const SKIP_AUTO_GENERATION_MARKER =
|
|
"{/* api-reference:skip-auto-generation */}";
|
|
const API_REFERENCE_START = "{/* api-reference:start */}";
|
|
const API_REFERENCE_END = "{/* api-reference:end */}";
|
|
|
|
type AuthoredSlot = {
|
|
kind: "api-manual" | "api-example";
|
|
name?: string;
|
|
content: string;
|
|
};
|
|
|
|
type PageSlots = {
|
|
manual?: string | undefined;
|
|
namedManual: Map<string, string>;
|
|
examples: Map<string, string>;
|
|
explicit: AuthoredSlot[];
|
|
};
|
|
|
|
type AuthoredFrontmatter = {
|
|
title?: string;
|
|
description?: string;
|
|
};
|
|
|
|
type AuthoredPageParts = {
|
|
frontmatter: AuthoredFrontmatter;
|
|
slots: PageSlots;
|
|
skipAutoGeneration: boolean;
|
|
};
|
|
|
|
type PageSummary = { slug: string; title: string; description: string };
|
|
|
|
function frontmatter(title: string, description: string): string {
|
|
return [
|
|
"---",
|
|
`title: ${yamlScalar(title)}`,
|
|
`description: ${yamlScalar(description)}`,
|
|
"---",
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
function yamlScalar(value: string): string {
|
|
return /^[A-Za-z0-9<][A-Za-z0-9 .,@'()&+<>/-]*$/.test(value)
|
|
? value
|
|
: JSON.stringify(value);
|
|
}
|
|
|
|
const META_PRINT_WIDTH = 80;
|
|
|
|
/** Serialize a meta.json object the way oxfmt would: scalars stay on one line
|
|
* and an array collapses onto a single line when it fits within the print
|
|
* width, breaking to one element per line only when it doesn't. Matching the
|
|
* formatter here keeps generated meta.json byte-identical across runs —
|
|
* `JSON.stringify(value, null, 2)` always explodes arrays multi-line, so the
|
|
* files would otherwise re-collapse on the next `oxfmt` pass and churn. */
|
|
function formatMetaJson(
|
|
meta: Record<string, string | readonly string[]>,
|
|
): string {
|
|
const indent = " ";
|
|
const entries = Object.entries(meta);
|
|
const lines = entries.map(([key, value], index) => {
|
|
const tail = index < entries.length - 1 ? "," : "";
|
|
const prefix = `${indent}${JSON.stringify(key)}: `;
|
|
if (!Array.isArray(value)) {
|
|
return `${prefix}${JSON.stringify(value)}${tail}`;
|
|
}
|
|
const inline = `[${value.map((item) => JSON.stringify(item)).join(", ")}]`;
|
|
if (prefix.length + inline.length + tail.length <= META_PRINT_WIDTH) {
|
|
return `${prefix}${inline}${tail}`;
|
|
}
|
|
const items = value
|
|
.map((item) => `${indent}${indent}${JSON.stringify(item)}`)
|
|
.join(",\n");
|
|
return `${prefix}[\n${items}\n${indent}]${tail}`;
|
|
});
|
|
return `{\n${lines.join("\n")}\n}\n`;
|
|
}
|
|
|
|
function titleForPage(page: string, exports: ExportInfo[]): string {
|
|
const primary = exports.filter((item) => item.pageRole === "primary");
|
|
if (primary.length === 1) return primary[0]!.name;
|
|
return titleCaseSlug(page);
|
|
}
|
|
|
|
function titleCaseSlug(slug: string): string {
|
|
return slug
|
|
.split("-")
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(" ");
|
|
}
|
|
|
|
const SECTION_TITLE_OVERRIDES: Partial<Record<ApiSection, string>> = {
|
|
"generative-ui": "Generative UI",
|
|
};
|
|
|
|
function sectionTitle(section: ApiSection): string {
|
|
return SECTION_TITLE_OVERRIDES[section] ?? titleCaseSlug(section);
|
|
}
|
|
|
|
function mdxEscape(value: string): string {
|
|
return value
|
|
.split(/(```[\s\S]*?```|`[^`]*`)/g)
|
|
.map((part) =>
|
|
part.startsWith("`")
|
|
? part
|
|
: part
|
|
.replaceAll("{", "\\{")
|
|
.replaceAll("}", "\\}")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">"),
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
function renderJsDocExample(value: string): string {
|
|
const trimmed = value.trim();
|
|
const example = trimmed.includes("```")
|
|
? trimmed
|
|
: ["```tsx", trimmed, "```"].join("\n");
|
|
return mdxEscape(example);
|
|
}
|
|
|
|
type ApiStatus = "stable" | "experimental" | "deprecated";
|
|
|
|
function isExperimentalDeprecation(deprecated?: string): boolean {
|
|
return /^(unstable \/ experimental|experimental\b|this (api|feature) is experimental\b|this api is (still )?under active development\b|under active development\b)/i.test(
|
|
deprecated ?? "",
|
|
);
|
|
}
|
|
|
|
function apiStatusForDeprecatedTag(deprecated?: string): ApiStatus {
|
|
if (!deprecated) return "stable";
|
|
return isExperimentalDeprecation(deprecated) ? "experimental" : "deprecated";
|
|
}
|
|
|
|
function apiStatusCallout(deprecated?: string): string[] {
|
|
if (!deprecated) return [];
|
|
if (apiStatusForDeprecatedTag(deprecated) === "experimental") {
|
|
return [
|
|
`<Callout type="tip">`,
|
|
`<strong>Experimental.</strong> ${mdxEscape(deprecated)}`,
|
|
`</Callout>`,
|
|
"",
|
|
];
|
|
}
|
|
return [
|
|
`<Callout type="warn">`,
|
|
`<strong>Deprecated.</strong> ${mdxEscape(deprecated)}`,
|
|
`</Callout>`,
|
|
"",
|
|
];
|
|
}
|
|
|
|
function mdxCommentMarker(name: string, boundary: "start" | "end"): string {
|
|
return `{/* ${name}:${boundary} */}`;
|
|
}
|
|
|
|
function extractManualSlot(source: string): string | undefined {
|
|
const regex =
|
|
/\{\/\*\s*api-manual:start\s*\*\/\}([\s\S]*?)\{\/\*\s*api-manual:end\s*\*\/\}/;
|
|
const match = source.match(regex);
|
|
return match
|
|
? [
|
|
mdxCommentMarker("api-manual", "start"),
|
|
(match[1] ?? "").trim(),
|
|
mdxCommentMarker("api-manual", "end"),
|
|
].join("\n")
|
|
: undefined;
|
|
}
|
|
|
|
function extractNamedManualSlots(source: string): Map<string, string> {
|
|
const slots = new Map<string, string>();
|
|
const regex =
|
|
/\{\/\*\s*api-manual:([^:\s]+):start\s*\*\/\}([\s\S]*?)\{\/\*\s*api-manual:\1:end\s*\*\/\}/g;
|
|
for (const match of source.matchAll(regex)) {
|
|
const name = match[1]!;
|
|
slots.set(
|
|
name,
|
|
[
|
|
mdxCommentMarker(`api-manual:${name}`, "start"),
|
|
(match[2] ?? "").trim(),
|
|
mdxCommentMarker(`api-manual:${name}`, "end"),
|
|
].join("\n"),
|
|
);
|
|
}
|
|
return slots;
|
|
}
|
|
|
|
function apiReferencePagePath(section: ApiSection, slug: string): string {
|
|
return path.join(API_REFERENCE_DIR, section, `${slug}.mdx`);
|
|
}
|
|
|
|
function exampleSlot(name: string, content: string): string {
|
|
return [
|
|
mdxCommentMarker(`api-example:${name}`, "start"),
|
|
content.trim(),
|
|
mdxCommentMarker(`api-example:${name}`, "end"),
|
|
].join("\n");
|
|
}
|
|
|
|
function extractExampleSlots(source: string): Map<string, string> {
|
|
const examples = new Map<string, string>();
|
|
const regex =
|
|
/\{\/\*\s*api-example:([^:\s]+):start\s*\*\/\}([\s\S]*?)\{\/\*\s*api-example:\1:end\s*\*\/\}/g;
|
|
for (const match of source.matchAll(regex)) {
|
|
const name = match[1]!;
|
|
examples.set(name, exampleSlot(name, match[2] ?? ""));
|
|
}
|
|
return examples;
|
|
}
|
|
|
|
function assertPairedMdxMarkers(filePath: string, source: string): void {
|
|
const startMarkerRegex = /\{\/\*\s*([^*]+?):start\s*\*\/\}/g;
|
|
const unclosed = [...source.matchAll(startMarkerRegex)]
|
|
.map((match) => match[1]!.trim())
|
|
.filter((name) => !source.includes(mdxCommentMarker(name, "end")));
|
|
if (unclosed.length === 0) return;
|
|
throw new Error(
|
|
`Unclosed marker found in ${path.relative(
|
|
REPO_ROOT,
|
|
filePath,
|
|
)}, generation stopped: ${unclosed.join(", ")}`,
|
|
);
|
|
}
|
|
|
|
function unquoteFrontmatterScalar(value: string): string {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return "";
|
|
if (
|
|
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
) {
|
|
try {
|
|
return trimmed.startsWith('"')
|
|
? JSON.parse(trimmed)
|
|
: trimmed.slice(1, -1).replaceAll("''", "'");
|
|
} catch {
|
|
throw new Error(`Invalid quoted frontmatter scalar: ${value}`);
|
|
}
|
|
}
|
|
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
|
throw new Error(`Unsupported frontmatter scalar: ${value}`);
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function readFrontmatter(
|
|
filePath: string,
|
|
source: string,
|
|
): AuthoredFrontmatter {
|
|
if (!source.startsWith("---\n")) return {};
|
|
const end = source.indexOf("\n---", 4);
|
|
if (end === -1) {
|
|
throw new Error(
|
|
`Malformed frontmatter in ${path.relative(REPO_ROOT, filePath)}`,
|
|
);
|
|
}
|
|
const fm: AuthoredFrontmatter = {};
|
|
for (const line of source.slice(4, end).split("\n")) {
|
|
if (!line.trim()) continue;
|
|
const match = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
|
|
if (!match) {
|
|
throw new Error(
|
|
`Unsupported frontmatter line in ${path.relative(REPO_ROOT, filePath)}: ${line}`,
|
|
);
|
|
}
|
|
const [, key, value] = match;
|
|
if (key === "title" || key === "description") {
|
|
fm[key] = unquoteFrontmatterScalar(value ?? "");
|
|
}
|
|
}
|
|
return fm;
|
|
}
|
|
|
|
function emptyPageSlots(): PageSlots {
|
|
return {
|
|
namedManual: new Map(),
|
|
examples: new Map(),
|
|
explicit: [],
|
|
};
|
|
}
|
|
|
|
function readAuthoredPageParts(
|
|
section: ApiSection,
|
|
slug: string,
|
|
items: ExportInfo[],
|
|
): AuthoredPageParts | undefined {
|
|
const filePath = apiReferencePagePath(section, slug);
|
|
let source: string;
|
|
try {
|
|
source = fs.readFileSync(filePath, "utf8");
|
|
} catch (error) {
|
|
if ((error as { code?: string }).code === "ENOENT") return undefined;
|
|
throw error;
|
|
}
|
|
assertPairedMdxMarkers(filePath, source);
|
|
const fm = readFrontmatter(filePath, source);
|
|
const manual = extractManualSlot(source);
|
|
const namedManual = extractNamedManualSlots(source);
|
|
const explicitExamples = extractExampleSlots(source);
|
|
const examples = new Map(explicitExamples);
|
|
const itemNames = new Set(
|
|
items.flatMap((item) => [
|
|
item.name,
|
|
...(item.section === "primitives"
|
|
? readPrimitiveParts(item.name).map((part) => `${item.name}.${part}`)
|
|
: []),
|
|
]),
|
|
);
|
|
for (const name of examples.keys()) {
|
|
if (!itemNames.has(name)) {
|
|
console.warn(
|
|
`Warning: api-example:${name} in ${path.relative(REPO_ROOT, filePath)} does not match an export on this page`,
|
|
);
|
|
}
|
|
}
|
|
const explicit: AuthoredSlot[] = [];
|
|
if (manual) explicit.push({ kind: "api-manual", content: manual });
|
|
for (const [name, content] of namedManual) {
|
|
explicit.push({ kind: "api-manual", name, content });
|
|
}
|
|
for (const [name, content] of explicitExamples) {
|
|
explicit.push({ kind: "api-example", name, content });
|
|
}
|
|
return {
|
|
frontmatter: fm,
|
|
slots: { manual, namedManual, examples, explicit },
|
|
skipAutoGeneration: source.includes(SKIP_AUTO_GENERATION_MARKER),
|
|
};
|
|
}
|
|
|
|
function assertPreservedSlots(
|
|
filePath: string,
|
|
slots: PageSlots,
|
|
nextSource: string,
|
|
): void {
|
|
const missing = slots.explicit.filter(
|
|
(slot) => !nextSource.includes(slot.content),
|
|
);
|
|
if (missing.length === 0) return;
|
|
throw new Error(
|
|
`Refusing to write ${path.relative(
|
|
REPO_ROOT,
|
|
filePath,
|
|
)} because generated output dropped authored slots: ${missing
|
|
.map((slot) => (slot.name ? `${slot.kind}:${slot.name}` : slot.kind))
|
|
.join(", ")}`,
|
|
);
|
|
}
|
|
|
|
const EXPORT_ORDER_BY_PAGE: Record<string, readonly string[]> = {
|
|
"tools/interactables": [
|
|
"unstable_Interactables",
|
|
"unstable_useInteractable",
|
|
"unstable_useInteractableState",
|
|
"unstable_useInteractableVersions",
|
|
"unstable_interactableTool",
|
|
"unstable_getInteractableSnapshots",
|
|
"unstable_formatInteractableSnapshot",
|
|
"unstable_getInteractableVersions",
|
|
],
|
|
"tools/interactables-legacy": [
|
|
"Interactables",
|
|
"useAssistantInteractable",
|
|
"useInteractableState",
|
|
],
|
|
};
|
|
|
|
const API_STATUS_ORDER = {
|
|
stable: 0,
|
|
experimental: 1,
|
|
deprecated: 2,
|
|
} satisfies Record<ApiStatus, number>;
|
|
|
|
const PAGE_ROLE_ORDER = {
|
|
primary: 0,
|
|
related: 1,
|
|
"supporting-type": 2,
|
|
} satisfies Record<ExportInfo["pageRole"], number>;
|
|
|
|
function exportSortKey(
|
|
item: ExportInfo,
|
|
): [number, number, number, number, string] {
|
|
const pageOrder = EXPORT_ORDER_BY_PAGE[`${item.section}/${item.page}`];
|
|
const pageIndex = pageOrder?.indexOf(item.name) ?? -1;
|
|
const orderIndex = pageIndex === -1 ? (pageOrder?.length ?? 0) : pageIndex;
|
|
return [
|
|
item.pageRole === "supporting-type" ? 1 : 0,
|
|
API_STATUS_ORDER[apiStatusForDeprecatedTag(item.deprecated)],
|
|
PAGE_ROLE_ORDER[item.pageRole],
|
|
orderIndex,
|
|
item.name,
|
|
];
|
|
}
|
|
|
|
function sortExportsForPage(items: ExportInfo[]): ExportInfo[] {
|
|
return [...items].sort((a, b) => {
|
|
const [aSupportingGroup, aStatusGroup, aRoleGroup, aOrderIndex, aName] =
|
|
exportSortKey(a);
|
|
const [bSupportingGroup, bStatusGroup, bRoleGroup, bOrderIndex, bName] =
|
|
exportSortKey(b);
|
|
if (aSupportingGroup !== bSupportingGroup) {
|
|
return aSupportingGroup - bSupportingGroup;
|
|
}
|
|
if (aStatusGroup !== bStatusGroup) return aStatusGroup - bStatusGroup;
|
|
if (aRoleGroup !== bRoleGroup) return aRoleGroup - bRoleGroup;
|
|
if (aOrderIndex !== bOrderIndex) return aOrderIndex - bOrderIndex;
|
|
return aName.localeCompare(bName);
|
|
});
|
|
}
|
|
|
|
function exportSection(
|
|
item: ExportInfo,
|
|
typeDocNames: Set<string>,
|
|
slots: PageSlots,
|
|
headingLevel = 2,
|
|
typeDocBindings: TypeDocBindings = new Map(),
|
|
): string[] {
|
|
if (!hasGeneratedEntryContent(item, typeDocNames, slots)) return [];
|
|
const heading = "#".repeat(headingLevel);
|
|
const lines = [`${heading} ${item.name}`, ""];
|
|
lines.push(...apiStatusCallout(item.deprecated));
|
|
if (item.jsDoc) {
|
|
lines.push(mdxEscape(item.jsDoc), "");
|
|
}
|
|
const example = slots.examples.get(item.name);
|
|
if (example) {
|
|
lines.push(example, "");
|
|
} else if (item.jsDocExamples) {
|
|
for (const jsDocExample of item.jsDocExamples) {
|
|
lines.push(renderJsDocExample(jsDocExample), "");
|
|
}
|
|
}
|
|
if (typeDocNames.has(item.name)) {
|
|
lines.push(
|
|
`<ParametersTable {...${typeDocBindings.get(item.name) ?? item.name}} />`,
|
|
"",
|
|
);
|
|
} else if (item.signature) {
|
|
lines.push(["```ts", item.signature, "```"].join("\n"), "");
|
|
}
|
|
const manual = slots.namedManual.get(item.name);
|
|
if (manual) lines.push(manual, "");
|
|
return lines;
|
|
}
|
|
|
|
function hasGeneratedEntryContent(
|
|
item: ExportInfo,
|
|
typeDocNames: Set<string>,
|
|
slots: PageSlots,
|
|
): boolean {
|
|
return Boolean(
|
|
item.jsDoc ||
|
|
item.jsDocExamples?.length ||
|
|
item.deprecated ||
|
|
typeDocNames.has(item.name) ||
|
|
item.signature ||
|
|
slots.namedManual.has(item.name) ||
|
|
slots.examples.has(item.name),
|
|
);
|
|
}
|
|
|
|
function isRenderedApiEntry(
|
|
item: ExportInfo,
|
|
typeDocNames: Set<string>,
|
|
slots: PageSlots,
|
|
): boolean {
|
|
return (
|
|
item.pageRole !== "supporting-type" &&
|
|
hasGeneratedEntryContent(item, typeDocNames, slots)
|
|
);
|
|
}
|
|
|
|
function generatePrimitivePage(
|
|
item: ExportInfo,
|
|
companions: ExportInfo[],
|
|
typeDocNames: Set<string>,
|
|
slots: PageSlots,
|
|
title: string,
|
|
description: string,
|
|
): string {
|
|
const guideFile = path.join(
|
|
REPO_ROOT,
|
|
"apps/docs/content/docs/primitives",
|
|
`${item.page}.mdx`,
|
|
);
|
|
const guideLine = fs.existsSync(guideFile)
|
|
? `For examples and usage patterns, see [${titleForPage(item.page, [item]).replace(/Primitive$/, "")}](/docs/primitives/${item.page}).`
|
|
: undefined;
|
|
return generateApiPage({
|
|
title,
|
|
description,
|
|
imports: generatedImportsForPage([item, ...companions], typeDocNames),
|
|
guideLine,
|
|
slots,
|
|
reference: generatePrimitiveReferenceRegion(
|
|
item,
|
|
companions,
|
|
typeDocNames,
|
|
slots,
|
|
),
|
|
});
|
|
}
|
|
|
|
function generatePrimitiveReferenceRegion(
|
|
item: ExportInfo,
|
|
companions: ExportInfo[],
|
|
typeDocNames: Set<string>,
|
|
slots: PageSlots,
|
|
): string {
|
|
const parts = readPrimitiveParts(item.name);
|
|
const lines = ["## API Reference", ""];
|
|
if (parts.length > 0) {
|
|
for (const part of parts) {
|
|
lines.push(`### ${part}`, "");
|
|
const partName = `${item.name}.${part}`;
|
|
const primitivePart = primitivePartFor(
|
|
item.name,
|
|
part,
|
|
item.jsDocRenderOptions,
|
|
);
|
|
const example = slots.examples.get(partName);
|
|
const examples = example
|
|
? [example]
|
|
: (primitivePart?.examples ?? []).map(renderJsDocExample);
|
|
lines.push(
|
|
primitiveParametersTable(
|
|
item.name,
|
|
part,
|
|
typeDocNames,
|
|
primitivePart,
|
|
examples,
|
|
),
|
|
"",
|
|
);
|
|
const manual = slots.namedManual.get(partName);
|
|
if (manual) lines.push(manual, "");
|
|
}
|
|
} else {
|
|
lines.push(...exportSection(item, typeDocNames, slots, 3));
|
|
}
|
|
if (typeDocNames.has(item.name)) {
|
|
lines.push(
|
|
`### ${item.name}`,
|
|
"",
|
|
`<ParametersTable {...${item.name}} />`,
|
|
"",
|
|
);
|
|
const manual = slots.namedManual.get(item.name);
|
|
if (manual) lines.push(manual, "");
|
|
}
|
|
// Helpers bound to a specific primitive part (e.g. groupPartByType for
|
|
// <MessagePrimitive.GroupedParts>) render after the primitive's own parts so
|
|
// they live beside the part they configure instead of in a fallback drawer.
|
|
for (const companion of sortExportsForPage(companions).filter((entry) =>
|
|
isRenderedApiEntry(entry, typeDocNames, slots),
|
|
)) {
|
|
lines.push(...exportSection(companion, typeDocNames, slots, 3));
|
|
}
|
|
return lines.join("\n").trimEnd();
|
|
}
|
|
|
|
function primitivePartFor(
|
|
primitiveName: string,
|
|
part: string,
|
|
options: ExportInfo["jsDocRenderOptions"],
|
|
) {
|
|
return extractPrimitivePartsFor(primitiveName, options).find(
|
|
(primitivePart) => primitivePart.partName === part,
|
|
);
|
|
}
|
|
|
|
function primitiveParametersTable(
|
|
primitiveName: string,
|
|
part: string,
|
|
typeDocNames: Set<string>,
|
|
primitivePart: PrimitivePartModel | undefined,
|
|
examples: string[],
|
|
): string {
|
|
const binding = `${primitiveName}Docs.${part}`;
|
|
const typeDocName = primitivePartTypeDocName(primitiveName, part);
|
|
const statusCallout =
|
|
apiStatusForDeprecatedTag(primitivePart?.deprecated) === "experimental"
|
|
? [
|
|
`{${binding}?.deprecated && (`,
|
|
` <Callout type="tip">`,
|
|
` <strong>Experimental.</strong> {${binding}.deprecated}`,
|
|
` </Callout>`,
|
|
`)}`,
|
|
]
|
|
: [
|
|
`{${binding}?.deprecated && (`,
|
|
` <Callout type="warn">`,
|
|
` <strong>Deprecated.</strong> {${binding}.deprecated}`,
|
|
` </Callout>`,
|
|
`)}`,
|
|
];
|
|
const table = typeDocNames.has(typeDocName)
|
|
? `<ParametersTable {...${typeDocName}} />`
|
|
: [
|
|
`{${binding}?.props && (`,
|
|
` <ParametersTable type={${binding}?.element} parameters={${binding}.props} />`,
|
|
`)}`,
|
|
].join("\n");
|
|
return [
|
|
...statusCallout,
|
|
"",
|
|
`{${binding}?.description}`,
|
|
"",
|
|
`{${binding}?.element && (`,
|
|
` <p>This primitive renders a <code>{\`<\${${binding}?.element}>\`}</code> element unless <code>asChild</code> is set.</p>`,
|
|
`)}`,
|
|
"",
|
|
...examples.flatMap((example) => [example, ""]),
|
|
table,
|
|
].join("\n");
|
|
}
|
|
|
|
function generateReferenceRegion(
|
|
items: ExportInfo[],
|
|
typeDocNames: Set<string>,
|
|
slots: PageSlots,
|
|
typeDocBindings: TypeDocBindings = new Map(),
|
|
): string {
|
|
return [
|
|
"## API Reference",
|
|
"",
|
|
...sortExportsForPage(items)
|
|
.filter((item) => isRenderedApiEntry(item, typeDocNames, slots))
|
|
.flatMap((item) =>
|
|
exportSection(item, typeDocNames, slots, 3, typeDocBindings),
|
|
),
|
|
]
|
|
.join("\n")
|
|
.trimEnd();
|
|
}
|
|
|
|
function generatedReferenceRegion(content: string): string {
|
|
return [
|
|
API_REFERENCE_START,
|
|
"{/* AUTO-GENERATED by scripts/generate-api-reference.mts */}",
|
|
"{/* Do not edit this block manually. */}",
|
|
"",
|
|
content.trim(),
|
|
API_REFERENCE_END,
|
|
].join("\n");
|
|
}
|
|
|
|
function generateApiPage({
|
|
title,
|
|
description,
|
|
imports,
|
|
guideLine,
|
|
slots,
|
|
reference,
|
|
}: {
|
|
title: string;
|
|
description: string;
|
|
imports: string;
|
|
guideLine?: string | undefined;
|
|
slots: PageSlots;
|
|
reference: string;
|
|
}): string {
|
|
const lines = [
|
|
frontmatter(title, description),
|
|
GENERATED_PAGE_MARKER,
|
|
"{/* Do not edit manually. */}",
|
|
"",
|
|
];
|
|
if (imports) lines.splice(1, 0, imports, "");
|
|
if (guideLine) lines.push(guideLine, "");
|
|
if (slots.manual) lines.push(slots.manual, "");
|
|
lines.push(generatedReferenceRegion(reference));
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
function generatedImportsForPage(
|
|
items: ExportInfo[],
|
|
typeDocNames: Set<string>,
|
|
): string {
|
|
const visibleItems = items.filter(
|
|
(item) => item.pageRole !== "supporting-type",
|
|
);
|
|
const primitives = visibleItems
|
|
.filter((item) => item.section === "primitives")
|
|
.map((item) => ({ name: item.name, parts: readPrimitiveParts(item.name) }));
|
|
const typeImports = visibleItems
|
|
.filter((item) => typeDocNames.has(item.name))
|
|
.map((item) => item.name);
|
|
const primitivePartTypeImports = primitives.flatMap((item) =>
|
|
item.parts
|
|
.map((part) => primitivePartTypeDocName(item.name, part))
|
|
.filter((name) => typeDocNames.has(name)),
|
|
);
|
|
const primitiveImports = primitives
|
|
.filter((item) => item.parts.length > 0)
|
|
.map((item) => item.name);
|
|
|
|
return generatedImports({
|
|
typeDocNames: [...typeImports, ...primitivePartTypeImports],
|
|
primitiveDocNames: primitiveImports,
|
|
});
|
|
}
|
|
|
|
function generatedIntegrationImports(typeDocBindings: TypeDocBindings): string {
|
|
const bindings = [...typeDocBindings.values()].sort();
|
|
if (bindings.length === 0) return "";
|
|
return `import { ${bindings.join(", ")} } from "@/generated/integrationTypeDocs";`;
|
|
}
|
|
|
|
function generatedImports({
|
|
typeDocNames,
|
|
primitiveDocNames,
|
|
}: {
|
|
typeDocNames: string[];
|
|
primitiveDocNames: string[];
|
|
}): string {
|
|
const lines: string[] = [];
|
|
const uniqueTypeDocs = [...new Set(typeDocNames)].sort();
|
|
if (uniqueTypeDocs.length > 0) {
|
|
lines.push(
|
|
`import { ${uniqueTypeDocs.join(", ")} } from "@/generated/typeDocs";`,
|
|
);
|
|
}
|
|
for (const primitiveName of [...new Set(primitiveDocNames)].sort()) {
|
|
lines.push(
|
|
`import { ${primitiveName} as ${primitiveName}Docs } from "@/generated/primitiveDocs";`,
|
|
);
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
|
|
const PAGE_ORDER_BY_SECTION: Partial<Record<ApiSection, readonly string[]>> = {
|
|
tools: [
|
|
"toolkits",
|
|
"component-tools",
|
|
"rendering",
|
|
"status",
|
|
"interactables",
|
|
"interactables-legacy",
|
|
],
|
|
"generative-ui": [
|
|
"json-generative-ui",
|
|
"components",
|
|
"actions",
|
|
"spec",
|
|
"rendering",
|
|
"tokens",
|
|
],
|
|
};
|
|
|
|
function comparePageSlugs(section: ApiSection, a: string, b: string): number {
|
|
const order = PAGE_ORDER_BY_SECTION[section];
|
|
const aIndex = order?.indexOf(a) ?? -1;
|
|
const bIndex = order?.indexOf(b) ?? -1;
|
|
if (aIndex !== bIndex) {
|
|
if (aIndex === -1) return 1;
|
|
if (bIndex === -1) return -1;
|
|
return aIndex - bIndex;
|
|
}
|
|
return a.localeCompare(b);
|
|
}
|
|
|
|
function generateSectionOverviewPage(
|
|
section: ApiSection,
|
|
pages: PageSummary[],
|
|
slots: PageSlots,
|
|
title: string,
|
|
description: string,
|
|
): string {
|
|
const cards = pages.flatMap((page) => [
|
|
` <Card title={${JSON.stringify(page.title)}} href={${JSON.stringify(`/docs/api-reference/${section}/${page.slug}`)}}>`,
|
|
` {${JSON.stringify(page.description)}}`,
|
|
" </Card>",
|
|
]);
|
|
return [
|
|
frontmatter(title, description),
|
|
GENERATED_PAGE_MARKER,
|
|
"{/* The page list is generated from exported APIs; edit only the manual prose slot. */}",
|
|
"",
|
|
...(slots.manual ? [slots.manual, ""] : []),
|
|
"## Pages",
|
|
"",
|
|
"<Cards>",
|
|
...cards,
|
|
"</Cards>",
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
function groupedBySectionAndPage(
|
|
exports: ExportInfo[],
|
|
): Map<ApiSection, Map<string, ExportInfo[]>> {
|
|
const result = new Map<ApiSection, Map<string, ExportInfo[]>>();
|
|
for (const section of SECTION_ORDER) result.set(section, new Map());
|
|
for (const item of exports) {
|
|
const section = result.get(item.section)!;
|
|
const items = section.get(item.page) ?? [];
|
|
items.push(item);
|
|
section.set(item.page, items);
|
|
}
|
|
for (const pages of result.values()) {
|
|
for (const [page, items] of pages) {
|
|
pages.set(page, sortExportsForPage(items));
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function writeSectionIndex(
|
|
section: ApiSection,
|
|
pages: PageSummary[],
|
|
description: string,
|
|
): void {
|
|
fs.writeFileSync(
|
|
path.join(API_REFERENCE_DIR, section, "meta.json"),
|
|
formatMetaJson({
|
|
title: sectionTitle(section),
|
|
description,
|
|
pages: ["index", ...pages.map((page) => page.slug)],
|
|
}),
|
|
);
|
|
}
|
|
|
|
function writeSectionOverviewIndex(
|
|
section: ApiSection,
|
|
sectionDir: string,
|
|
pageSummaries: PageSummary[],
|
|
fallbackTitle: string,
|
|
): void {
|
|
const indexPath = path.join(sectionDir, "index.mdx");
|
|
const indexAuthored = readAuthoredPageParts(section, "index", []);
|
|
const indexSlots = indexAuthored?.slots ?? emptyPageSlots();
|
|
const indexTitle = authoredTitleOrSeed(
|
|
indexPath,
|
|
indexAuthored?.frontmatter,
|
|
fallbackTitle,
|
|
);
|
|
const indexDescription = authoredDescription(indexAuthored?.frontmatter);
|
|
const indexBody = generateSectionOverviewPage(
|
|
section,
|
|
pageSummaries,
|
|
indexSlots,
|
|
indexTitle,
|
|
indexDescription,
|
|
);
|
|
assertPreservedSlots(indexPath, indexSlots, indexBody);
|
|
fs.writeFileSync(indexPath, indexBody);
|
|
validatePageDescription(indexPath, indexAuthored?.frontmatter, []);
|
|
writeSectionIndex(section, pageSummaries, indexDescription);
|
|
}
|
|
|
|
function writeApiReferenceRoot(): void {
|
|
fs.mkdirSync(API_REFERENCE_DIR, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(API_REFERENCE_DIR, "meta.json"),
|
|
formatMetaJson({
|
|
title: "API Reference",
|
|
pages: ["overview", ...SECTION_ORDER],
|
|
}),
|
|
);
|
|
}
|
|
|
|
function authoredTitleOrSeed(
|
|
filePath: string,
|
|
fm: AuthoredFrontmatter | undefined,
|
|
seed: string,
|
|
): string {
|
|
if (fm?.title) return fm.title;
|
|
console.warn(
|
|
`Missing title in ${path.relative(REPO_ROOT, filePath)}. Generated mechanical title: ${seed}`,
|
|
);
|
|
return seed;
|
|
}
|
|
|
|
function authoredDescription(fm: AuthoredFrontmatter | undefined): string {
|
|
return fm?.description?.trim() ?? "";
|
|
}
|
|
|
|
function validatePageDescription(
|
|
filePath: string,
|
|
fm: AuthoredFrontmatter | undefined,
|
|
exports: ExportInfo[],
|
|
): void {
|
|
const desc = authoredDescription(fm);
|
|
const primary = exports.find((item) => item.pageRole === "primary");
|
|
if (!desc) {
|
|
console.warn(
|
|
`Missing description in ${path.relative(
|
|
REPO_ROOT,
|
|
filePath,
|
|
)} (new API export: ${primary?.name ?? "?"}).\n` +
|
|
"You must manually write the description frontmatter for new generated pages, matching the style of the other api-reference pages.",
|
|
);
|
|
return;
|
|
}
|
|
const minLen = 50;
|
|
const maxLen = 250;
|
|
if (desc.length < minLen || desc.length > maxLen) {
|
|
console.warn(
|
|
`Description in ${path.relative(
|
|
REPO_ROOT,
|
|
filePath,
|
|
)} is ${desc.length} chars (expected ${minLen}-${maxLen}).`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function pruneGeneratedPage(filePath: string): void {
|
|
let source: string;
|
|
try {
|
|
source = fs.readFileSync(filePath, "utf8");
|
|
} catch (error) {
|
|
if ((error as { code?: string }).code === "ENOENT") return;
|
|
throw error;
|
|
}
|
|
if (source.includes(SKIP_AUTO_GENERATION_MARKER)) return;
|
|
if (source.includes(GENERATED_PAGE_MARKER)) fs.unlinkSync(filePath);
|
|
}
|
|
|
|
function pruneStaleGeneratedPages(
|
|
sectionDir: string,
|
|
expectedSlugs: Set<string>,
|
|
): void {
|
|
if (!fs.existsSync(sectionDir)) return;
|
|
for (const entry of fs.readdirSync(sectionDir, { withFileTypes: true })) {
|
|
if (!entry.isFile() || !entry.name.endsWith(".mdx")) continue;
|
|
const slug = entry.name.replace(/\.mdx$/, "");
|
|
if (expectedSlugs.has(slug)) continue;
|
|
const filePath = path.join(sectionDir, entry.name);
|
|
pruneGeneratedPage(filePath);
|
|
}
|
|
}
|
|
|
|
export function writeApiReferencePages(
|
|
exports: ExportInfo[],
|
|
typeDocs: Map<string, TypeDoc>,
|
|
integrationsByPackage: Array<{
|
|
slug: string;
|
|
items: ExportInfo[];
|
|
typeDocBindings: TypeDocBindings;
|
|
typeDocNames: Set<string>;
|
|
}>,
|
|
): void {
|
|
writeApiReferenceRoot();
|
|
const typeDocNames = new Set(typeDocs.keys());
|
|
const grouped = groupedBySectionAndPage(exports);
|
|
|
|
for (const section of REACT_API_SECTIONS) {
|
|
const sectionDir = path.join(API_REFERENCE_DIR, section);
|
|
fs.mkdirSync(sectionDir, { recursive: true });
|
|
|
|
const pages = [
|
|
...(grouped.get(section) ?? new Map<string, ExportInfo[]>()).entries(),
|
|
]
|
|
.filter(([, items]) => items.some((item) => item.pageRole === "primary"))
|
|
.sort(([a], [b]) => comparePageSlugs(section, a, b));
|
|
|
|
const pageSummaries: PageSummary[] = [];
|
|
|
|
for (const [slug, items] of pages) {
|
|
const filePath = path.join(sectionDir, `${slug}.mdx`);
|
|
const authored = readAuthoredPageParts(section, slug, items);
|
|
const title = authoredTitleOrSeed(
|
|
filePath,
|
|
authored?.frontmatter,
|
|
titleForPage(slug, items),
|
|
);
|
|
const description = authoredDescription(authored?.frontmatter);
|
|
pageSummaries.push({ slug, title, description });
|
|
|
|
if (authored?.skipAutoGeneration) {
|
|
console.log(
|
|
`Skipping auto-generation for API page: ${path.relative(REPO_ROOT, filePath)}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const slots = authored?.slots ?? emptyPageSlots();
|
|
// A primitive page is anchored by exactly one primary primitive; any
|
|
// other exports on the page (related helpers, supporting types) render
|
|
// as companions after the primitive's parts. This keeps the parts-table
|
|
// layout while letting a part-bound helper share the page.
|
|
const primitivePrimaries =
|
|
section === "primitives"
|
|
? items.filter((item) => item.pageRole === "primary")
|
|
: [];
|
|
const primitivePrimary =
|
|
primitivePrimaries.length === 1 ? primitivePrimaries[0] : undefined;
|
|
const body = primitivePrimary
|
|
? generatePrimitivePage(
|
|
primitivePrimary,
|
|
items.filter((item) => item !== primitivePrimary),
|
|
typeDocNames,
|
|
slots,
|
|
title,
|
|
description,
|
|
)
|
|
: generateApiPage({
|
|
title,
|
|
description,
|
|
imports: generatedImportsForPage(items, typeDocNames),
|
|
slots,
|
|
reference: generateReferenceRegion(items, typeDocNames, slots),
|
|
});
|
|
|
|
assertPreservedSlots(filePath, slots, body);
|
|
fs.writeFileSync(filePath, body);
|
|
validatePageDescription(filePath, authored?.frontmatter, items);
|
|
}
|
|
|
|
writeSectionOverviewIndex(
|
|
section,
|
|
sectionDir,
|
|
pageSummaries,
|
|
`${sectionTitle(section)} API Reference`,
|
|
);
|
|
pruneStaleGeneratedPages(
|
|
sectionDir,
|
|
new Set(["index", ...pageSummaries.map((page) => page.slug)]),
|
|
);
|
|
}
|
|
|
|
writeIntegrationPages(integrationsByPackage);
|
|
}
|
|
|
|
function writeIntegrationPages(
|
|
integrationsByPackage: Array<{
|
|
slug: string;
|
|
items: ExportInfo[];
|
|
typeDocBindings: TypeDocBindings;
|
|
typeDocNames: Set<string>;
|
|
}>,
|
|
): void {
|
|
const section: ApiSection = "integrations";
|
|
const sectionDir = path.join(API_REFERENCE_DIR, section);
|
|
fs.mkdirSync(sectionDir, { recursive: true });
|
|
|
|
const integrationBySlug = new Map<
|
|
string,
|
|
(typeof INTEGRATION_PACKAGES)[number]
|
|
>(INTEGRATION_PACKAGES.map((p) => [p.slug, p]));
|
|
|
|
const pageSummaries: PageSummary[] = [];
|
|
|
|
for (const {
|
|
slug,
|
|
items,
|
|
typeDocBindings,
|
|
typeDocNames,
|
|
} of integrationsByPackage) {
|
|
const integration = integrationBySlug.get(slug);
|
|
if (!integration) continue;
|
|
const filePath = path.join(sectionDir, `${integration.slug}.mdx`);
|
|
const authored = readAuthoredPageParts(section, integration.slug, items);
|
|
const slots = authored?.slots ?? emptyPageSlots();
|
|
const title = authoredTitleOrSeed(
|
|
filePath,
|
|
authored?.frontmatter,
|
|
integration.packageName,
|
|
);
|
|
const description = authoredDescription(authored?.frontmatter);
|
|
pageSummaries.push({ slug: integration.slug, title, description });
|
|
const reference = generateReferenceRegion(
|
|
items,
|
|
typeDocNames,
|
|
slots,
|
|
typeDocBindings,
|
|
);
|
|
const body = generateApiPage({
|
|
title,
|
|
description,
|
|
imports: generatedIntegrationImports(typeDocBindings),
|
|
slots,
|
|
reference,
|
|
});
|
|
assertPreservedSlots(filePath, slots, body);
|
|
fs.writeFileSync(filePath, body);
|
|
validatePageDescription(filePath, authored?.frontmatter, items);
|
|
}
|
|
|
|
writeSectionOverviewIndex(
|
|
section,
|
|
sectionDir,
|
|
pageSummaries,
|
|
"Integrations API Reference",
|
|
);
|
|
|
|
pruneStaleGeneratedPages(
|
|
sectionDir,
|
|
new Set([
|
|
"index",
|
|
...INTEGRATION_PACKAGES.map((integration) => integration.slug),
|
|
]),
|
|
);
|
|
}
|
|
|
|
export function printClassificationDiagnostics(
|
|
exports: ExportInfo[],
|
|
typeDocs: Map<string, TypeDoc>,
|
|
): void {
|
|
const typeDocNames = new Set(typeDocs.keys());
|
|
// No authored slots here — "rendered" means the export carries generated
|
|
// content (jsDoc, examples, signature, or a typeDoc table) on its own.
|
|
const noSlots = emptyPageSlots();
|
|
const ruleCounts = new Map<string, number>();
|
|
const renderedFallbacks: ExportInfo[] = [];
|
|
let hiddenFallbackCount = 0;
|
|
let fallbackSupportingTypeCount = 0;
|
|
for (const item of exports) {
|
|
ruleCounts.set(
|
|
item.classificationRule,
|
|
(ruleCounts.get(item.classificationRule) ?? 0) + 1,
|
|
);
|
|
if (item.classificationConfidence !== "fallback") continue;
|
|
if (item.pageRole === "supporting-type") {
|
|
fallbackSupportingTypeCount += 1;
|
|
} else if (hasGeneratedEntryContent(item, typeDocNames, noSlots)) {
|
|
renderedFallbacks.push(item);
|
|
} else {
|
|
hiddenFallbackCount += 1;
|
|
}
|
|
}
|
|
console.log("API reference classification rules:");
|
|
for (const [rule, count] of [...ruleCounts.entries()].sort(([a], [b]) =>
|
|
a.localeCompare(b),
|
|
)) {
|
|
console.log(` ${rule}: ${count}`);
|
|
}
|
|
// Only rendered fallbacks are actionable (they show up in the junk drawer and
|
|
// want a real home); list them with source paths. Hidden fallbacks (no
|
|
// rendered content) and supporting types are collapsed to a count.
|
|
if (renderedFallbacks.length > 0) {
|
|
console.log("Fallback-classified exports needing a home:");
|
|
for (const item of renderedFallbacks.sort((a, b) =>
|
|
a.name.localeCompare(b.name),
|
|
)) {
|
|
console.log(` ${item.name} (${item.sourcePath ?? "unknown source"})`);
|
|
}
|
|
}
|
|
if (hiddenFallbackCount > 0) {
|
|
console.log(
|
|
`Fallback-classified exports with no rendered content: ${hiddenFallbackCount}`,
|
|
);
|
|
}
|
|
if (fallbackSupportingTypeCount > 0) {
|
|
console.log(
|
|
`Fallback-classified supporting types: ${fallbackSupportingTypeCount}`,
|
|
);
|
|
}
|
|
if (process.env.API_REFERENCE_CLASSIFICATION_VERBOSE === "1") {
|
|
console.log("API reference export classifications:");
|
|
for (const item of [...exports].sort((a, b) =>
|
|
a.name.localeCompare(b.name),
|
|
)) {
|
|
console.log(
|
|
` ${item.name}: ${item.section}/${item.page} (${item.pageRole}, ${item.classificationRule})`,
|
|
);
|
|
}
|
|
}
|
|
}
|