feat(docs): stop hiding half the component API — typed emits, exposes, flow guide
Publish to NPM / Check version changes and publish (push) Has been cancelled
Publish to NPM / Check version changes and publish (push) Has been cancelled
The per-part regex only saw defineEmits<{ inline literal }>(), so a named
interface — how Flow, Popover, Dialog, Menu and Drawer all declare their
events — extracted as zero emits, and defineExpose was not extracted at
all: 36 components' template-ref surfaces were invisible. Consumers
rebuilt what existed (nodeDragStop from @nodes-change, a renderless
child to reach fitView).
- extractor: one type-checking project per components package with a
virtual <file>.vue.ts mirror per SFC, so cross-file emits interfaces
(extends included) and expose spreads resolve through the checker —
...api expands into the composable's full return with its JSDoc
- parts gain exposes; emits/exposes members carry their JSDoc text;
update:* model emits get a stock description
- UI: Description column on emits, an Exposes (template ref) table; MCP
get_doc renders the same
- FlowRoot: JSDoc on every emit and exposed member
- new primitives guide page: building flow graphs — sizing, custom
nodes, .nodrag, click family, instance API, edge labels
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -89,7 +89,12 @@ const roleColor: Record<string, string> = {
|
||||
<DocsEmitsTable :emits="part.emits" />
|
||||
</div>
|
||||
|
||||
<p v-if="part.props.length === 0 && part.emits.length === 0" class="text-sm text-fg-subtle italic">
|
||||
<div v-if="part.exposes?.length" class="mb-3">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wider text-fg-subtle mb-2">Exposes (template ref)</div>
|
||||
<DocsExposesTable :exposes="part.exposes" />
|
||||
</div>
|
||||
|
||||
<p v-if="part.props.length === 0 && part.emits.length === 0 && !part.exposes?.length" class="text-sm text-fg-subtle italic">
|
||||
No props or events — renders its element and forwards attributes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ defineProps<{
|
||||
<tr class="bg-bg-subtle text-left">
|
||||
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Event</th>
|
||||
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Payload</th>
|
||||
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -22,6 +23,10 @@ defineProps<{
|
||||
<td class="py-2.5 px-4">
|
||||
<code class="text-xs font-mono text-fg-muted bg-bg-inset px-1.5 py-0.5 rounded border border-border wrap-break-word">{{ e.payload }}</code>
|
||||
</td>
|
||||
<td class="py-2.5 px-4 text-fg-muted min-w-48">
|
||||
<DocsText v-if="e.description" :text="e.description" />
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">import type { PropertyMeta } from '../../modules/extractor/types';
|
||||
|
||||
defineProps<{
|
||||
exposes: PropertyMeta[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="exposes.length > 0" class="overflow-x-auto rounded-xl border border-border">
|
||||
<table class="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-bg-subtle text-left">
|
||||
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Name</th>
|
||||
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Type</th>
|
||||
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="x in exposes" :key="x.name" class="border-t border-border align-top">
|
||||
<td class="py-2.5 px-4 whitespace-nowrap">
|
||||
<code class="text-accent-text font-mono text-[13px] font-medium">{{ x.name }}</code><span v-if="x.optional" class="text-fg-subtle text-xs">?</span>
|
||||
</td>
|
||||
<td class="py-2.5 px-4">
|
||||
<code class="text-xs font-mono text-fg-muted bg-bg-inset px-1.5 py-0.5 rounded border border-border wrap-break-word">{{ x.type }}</code>
|
||||
</td>
|
||||
<td class="py-2.5 px-4 text-fg-muted min-w-48">
|
||||
<DocsText v-if="x.description" :text="x.description" />
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import { basename, dirname, relative, resolve } from 'node:path';
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { Node, Project, SyntaxKind } from 'ts-morph';
|
||||
import { Node, Project, SyntaxKind, ts } from 'ts-morph';
|
||||
import type { ClassDeclaration, FunctionDeclaration, InterfaceDeclaration, JSDoc, JSDocTag, MethodDeclaration, PropertyDeclaration, PropertySignature, SourceFile, TypeAliasDeclaration, VariableDeclaration } from 'ts-morph';
|
||||
import type {
|
||||
CategoryMeta,
|
||||
@@ -858,6 +858,144 @@ function extractScriptBlock(sfc: string, setup: boolean): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
// ── SFC type project ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One type-checking project per components package: every real `src/**\/*.ts`
|
||||
* file plus, for each SFC part, a virtual `<file>.vue.ts` mirror holding its
|
||||
* two script blocks. TS resolves a `./X.vue` specifier by appending `.ts`, so
|
||||
* the mirrors make cross-file shapes resolve for real — `defineEmits<XEmits>()`
|
||||
* where the interface lives in another block, a sibling `.ts` or another SFC,
|
||||
* and `defineExpose({ ...api })` where the spread's type is a composable's
|
||||
* return. The per-part regexes never saw any of those, which is exactly how
|
||||
* half of Flow's API ended up invisible in the docs.
|
||||
*/
|
||||
function buildSfcProject(pkgDir: string): Project {
|
||||
const srcDir = resolve(pkgDir, 'src');
|
||||
const tsconfigPath = resolve(pkgDir, 'tsconfig.json');
|
||||
const project = new Project({
|
||||
tsConfigFilePath: existsSync(tsconfigPath) ? tsconfigPath : undefined,
|
||||
skipAddingFilesFromTsConfig: true,
|
||||
});
|
||||
|
||||
project.addSourceFilesAtPaths([`${srcDir}/**/*.ts`, `!${srcDir}/**/__test__/**`]);
|
||||
|
||||
for (const entry of readdirSync(srcDir, { recursive: true, withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.vue') || entry.name === 'demo.vue') continue;
|
||||
|
||||
const full = resolve(entry.parentPath, entry.name);
|
||||
if (full.includes('__test__')) continue;
|
||||
|
||||
const sfc = readFileSync(full, 'utf-8');
|
||||
const script = `${extractScriptBlock(sfc, false)}\n${extractScriptBlock(sfc, true)}`;
|
||||
if (script.trim()) project.createSourceFile(`${full}.ts`, script, { overwrite: true });
|
||||
}
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
/** Type display: keep alias names (`Ref<T>`, not its expansion), never truncate. */
|
||||
const TYPE_TEXT_FLAGS = ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope | ts.TypeFormatFlags.NoTruncation;
|
||||
|
||||
/** JSDoc description of a declaration; a const's doc sits on its statement. */
|
||||
function describeDecl(node: Node | undefined): string {
|
||||
if (!node) return '';
|
||||
const holder = Node.isVariableDeclaration(node) ? node.getVariableStatement() ?? node : node;
|
||||
if (!Node.isJSDocable(holder)) return '';
|
||||
const jsdocs = holder.getJsDocs();
|
||||
return getDescription(jsdocs, getJsDocTags(jsdocs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits through the checker's view of `defineEmits<T>()`: the inline literal
|
||||
* AND a named interface (same block, sibling `.ts`, another SFC via the
|
||||
* mirrors), `extends` chains included — with each member's JSDoc.
|
||||
*/
|
||||
function extractEmitsFrom(sf: SourceFile): EmitMeta[] {
|
||||
const call = sf.getDescendantsOfKind(SyntaxKind.CallExpression)
|
||||
.find(c => c.getExpression().getText() === 'defineEmits');
|
||||
const typeArg = call?.getTypeArguments()[0];
|
||||
if (!call || !typeArg) return [];
|
||||
|
||||
const emits: EmitMeta[] = [];
|
||||
for (const prop of typeArg.getType().getProperties()) {
|
||||
const decl = prop.getDeclarations()[0];
|
||||
const written = decl && Node.isPropertySignature(decl) ? decl.getTypeNode()?.getText() : undefined;
|
||||
|
||||
emits.push({
|
||||
name: prop.getName(),
|
||||
payload: cleanType(written ?? prop.getTypeAtLocation(call).getText(call, TYPE_TEXT_FLAGS)),
|
||||
description: describeDecl(decl),
|
||||
});
|
||||
}
|
||||
|
||||
return emits;
|
||||
}
|
||||
|
||||
/**
|
||||
* `defineExpose({ … })` → the template-ref surface. Spreads expand through the
|
||||
* checker (`...api` lists every member of the composable's return type with its
|
||||
* JSDoc), so the docs show the full instance API instead of nothing at all.
|
||||
*/
|
||||
function extractExposesFrom(sf: SourceFile): PropertyMeta[] {
|
||||
const call = sf.getDescendantsOfKind(SyntaxKind.CallExpression)
|
||||
.find(c => c.getExpression().getText() === 'defineExpose');
|
||||
const arg = call?.getArguments()[0];
|
||||
if (!arg || !Node.isObjectLiteralExpression(arg)) return [];
|
||||
|
||||
const out: PropertyMeta[] = [];
|
||||
const push = (name: string, type: string, description: string, optional = false) => {
|
||||
if (!out.some(p => p.name === name))
|
||||
out.push({ name, type: cleanType(type), description, optional, defaultValue: null, readonly: false });
|
||||
};
|
||||
|
||||
for (const member of arg.getProperties()) {
|
||||
if (Node.isSpreadAssignment(member)) {
|
||||
const spreadType = member.getExpression().getType();
|
||||
const props = spreadType.getProperties();
|
||||
|
||||
// Unresolvable spread — surface it verbatim rather than dropping it.
|
||||
if (spreadType.isAny() || props.length === 0) {
|
||||
push(member.getText(), '', '');
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const prop of props) {
|
||||
const decl = prop.getDeclarations()[0];
|
||||
const written = decl && Node.isPropertySignature(decl) ? decl.getTypeNode()?.getText() : undefined;
|
||||
push(
|
||||
prop.getName(),
|
||||
written ?? prop.getTypeAtLocation(member).getText(member, TYPE_TEXT_FLAGS),
|
||||
describeDecl(decl),
|
||||
decl !== undefined && Node.isQuestionTokenable(decl) && decl.hasQuestionToken(),
|
||||
);
|
||||
}
|
||||
}
|
||||
else if (Node.isShorthandPropertyAssignment(member)) {
|
||||
const local = sf.getProject().getTypeChecker().getShorthandAssignmentValueSymbol(member);
|
||||
push(
|
||||
member.getName(),
|
||||
member.getType().getText(member, TYPE_TEXT_FLAGS),
|
||||
describeDecl(local?.getDeclarations()[0]),
|
||||
);
|
||||
}
|
||||
else if (Node.isPropertyAssignment(member)) {
|
||||
const init = member.getInitializer();
|
||||
const initDecl = init && Node.isIdentifier(init) ? init.getSymbol()?.getDeclarations()[0] : undefined;
|
||||
push(
|
||||
member.getName().replaceAll(/^['"]|['"]$/g, ''),
|
||||
(init ?? member).getType().getText(member, TYPE_TEXT_FLAGS),
|
||||
describeDecl(member) || describeDecl(initDecl),
|
||||
);
|
||||
}
|
||||
else if (Node.isMethodDeclaration(member)) {
|
||||
push(member.getName(), member.getType().getText(member, TYPE_TEXT_FLAGS), describeDecl(member));
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Parse `defineEmits<{ 'a': [x: T]; b: [] }>()` from a setup block. */
|
||||
function extractEmits(setupScript: string): EmitMeta[] {
|
||||
const m = setupScript.match(/defineEmits<\{([\s\S]*?)\}>\s*\(\s*\)/);
|
||||
@@ -907,7 +1045,7 @@ function extractModels(setupScript: string): { props: PropertyMeta[]; emits: Emi
|
||||
defaultValue: null,
|
||||
readonly: false,
|
||||
});
|
||||
emits.push({ name: `update:${name}`, payload: `[value: ${type}]`, description: '' });
|
||||
emits.push({ name: `update:${name}`, payload: `[value: ${type}]`, description: `Emitted when \`v-model${name === 'modelValue' ? '' : `:${name}`}\` updates.` });
|
||||
}
|
||||
|
||||
return { props, emits };
|
||||
@@ -968,7 +1106,7 @@ function roleFromName(componentName: string, base: string): string {
|
||||
* not a component group (no `.vue`). `category` is the display label; `entryPoint`
|
||||
* is the package subpath (e.g. `./forms/checkbox`).
|
||||
*/
|
||||
function buildComponentAt(dir: string, slug: string, category: string, entryPoint: string): ComponentMeta | null {
|
||||
function buildComponentAt(dir: string, slug: string, category: string, entryPoint: string, sfcProject?: Project): ComponentMeta | null {
|
||||
// A component group is any dir that ships at least one .vue file.
|
||||
const vueFiles = readdirSync(dir).filter(f => f.endsWith('.vue'));
|
||||
if (vueFiles.length === 0) return null;
|
||||
@@ -1001,16 +1139,22 @@ function buildComponentAt(dir: string, slug: string, category: string, entryPoin
|
||||
const role = roleFromName(name, base);
|
||||
if (role === 'Root' && description && !groupDescription) groupDescription = description;
|
||||
|
||||
// Emits/exposes come from the typed SFC project when it has this part;
|
||||
// the regex parser stays as the fallback for inline-literal emits.
|
||||
const virtual = sfcProject?.getSourceFile(`${resolve(dir, file)}.ts`);
|
||||
let emits = virtual ? extractEmitsFrom(virtual) : [];
|
||||
if (emits.length === 0) emits = extractEmits(setup);
|
||||
const exposes = virtual ? extractExposesFrom(virtual) : [];
|
||||
|
||||
// Merge in `defineModel` v-model props/emits (invisible to the interface/
|
||||
// defineEmits parsers), de-duping against any explicitly-declared ones.
|
||||
const models = extractModels(setup);
|
||||
const emits = extractEmits(setup);
|
||||
for (const mp of models.props)
|
||||
if (!props.some(p => p.name === mp.name)) props.push(mp);
|
||||
for (const me of models.emits)
|
||||
if (!emits.some(e => e.name === me.name)) emits.push(me);
|
||||
|
||||
parts.push({ name, role, description, props, emits });
|
||||
parts.push({ name, role, description, props, emits, exposes });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1030,6 +1174,7 @@ function buildComponents(pkgDir: string): ComponentMeta[] {
|
||||
const srcDir = resolve(pkgDir, 'src');
|
||||
if (!existsSync(srcDir)) return [];
|
||||
|
||||
const sfcProject = buildSfcProject(pkgDir);
|
||||
const components: ComponentMeta[] = [];
|
||||
|
||||
// Components live one level deep, in category folders: src/<category>/<component>/.
|
||||
@@ -1048,13 +1193,14 @@ function buildComponents(pkgDir: string): ComponentMeta[] {
|
||||
compEntry.name,
|
||||
label,
|
||||
`./${catEntry.name}/${compEntry.name}`,
|
||||
sfcProject,
|
||||
);
|
||||
if (c) components.push(c);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Backward-compat: a flat component dir directly under src.
|
||||
const c = buildComponentAt(catDir, catEntry.name, 'Other', `./${catEntry.name}`);
|
||||
const c = buildComponentAt(catDir, catEntry.name, 'Other', `./${catEntry.name}`, sfcProject);
|
||||
if (c) components.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,11 @@ export interface ComponentPartMeta {
|
||||
props: PropertyMeta[];
|
||||
/** Emitted events parsed from `defineEmits` */
|
||||
emits: EmitMeta[];
|
||||
/**
|
||||
* The template-ref surface parsed from `defineExpose`, spreads expanded
|
||||
* through the type checker (`...api` lists the composable's whole return).
|
||||
*/
|
||||
exposes?: PropertyMeta[];
|
||||
}
|
||||
|
||||
export interface EmitMeta {
|
||||
|
||||
@@ -226,6 +226,11 @@ function renderComponentPart(part: ComponentPartMeta): string[] {
|
||||
const rows = part.emits.map(e => [cell(e.name), cell(`\`${e.payload}\``), cell(e.description)]);
|
||||
out.push('#### Emits', '', table(['Event', 'Payload', 'Description'], rows), '');
|
||||
}
|
||||
|
||||
if (part.exposes && part.exposes.length > 0) {
|
||||
const rows = part.exposes.map(x => [cell(x.name), cell(`\`${x.type}\``), cell(x.description)]);
|
||||
out.push('#### Exposes (template ref)', '', table(['Name', 'Type', 'Description'], rows), '');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user