diff --git a/docs/app/components/DocsComponentAnatomy.vue b/docs/app/components/DocsComponentAnatomy.vue index 0a5787d..80c5523 100644 --- a/docs/app/components/DocsComponentAnatomy.vue +++ b/docs/app/components/DocsComponentAnatomy.vue @@ -89,7 +89,12 @@ const roleColor: Record = { -

+

+
Exposes (template ref)
+ +
+ +

No props or events — renders its element and forwards attributes.

diff --git a/docs/app/components/DocsEmitsTable.vue b/docs/app/components/DocsEmitsTable.vue index 53e7c5f..3727c60 100644 --- a/docs/app/components/DocsEmitsTable.vue +++ b/docs/app/components/DocsEmitsTable.vue @@ -12,6 +12,7 @@ defineProps<{ Event Payload + Description @@ -22,6 +23,10 @@ defineProps<{ {{ e.payload }} + + + + diff --git a/docs/app/components/DocsExposesTable.vue b/docs/app/components/DocsExposesTable.vue new file mode 100644 index 0000000..5bed923 --- /dev/null +++ b/docs/app/components/DocsExposesTable.vue @@ -0,0 +1,34 @@ + + + diff --git a/docs/modules/extractor/extract.ts b/docs/modules/extractor/extract.ts index 19baa49..aba0b61 100644 --- a/docs/modules/extractor/extract.ts +++ b/docs/modules/extractor/extract.ts @@ -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 `.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()` + * 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`, 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()`: 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///. @@ -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); } } diff --git a/docs/modules/extractor/types.ts b/docs/modules/extractor/types.ts index 16491fd..8c3433f 100644 --- a/docs/modules/extractor/types.ts +++ b/docs/modules/extractor/types.ts @@ -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 { diff --git a/docs/modules/mcp/format.ts b/docs/modules/mcp/format.ts index 46a4ca8..75bff91 100644 --- a/docs/modules/mcp/format.ts +++ b/docs/modules/mcp/format.ts @@ -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; } diff --git a/vue/primitives/docs/01-flow-graphs.vue b/vue/primitives/docs/01-flow-graphs.vue new file mode 100644 index 0000000..d7516fb --- /dev/null +++ b/vue/primitives/docs/01-flow-graphs.vue @@ -0,0 +1,191 @@ + + + + diff --git a/vue/primitives/src/canvas/flow/FlowRoot.vue b/vue/primitives/src/canvas/flow/FlowRoot.vue index 0677829..f391dfa 100644 --- a/vue/primitives/src/canvas/flow/FlowRoot.vue +++ b/vue/primitives/src/canvas/flow/FlowRoot.vue @@ -86,16 +86,27 @@ export interface FlowRootProps extends PrimitiveProps { } export interface FlowRootEmits { + /** Granular node mutations (position, selection, removal) — apply them to your controlled state. */ nodesChange: [changes: NodeChange[]]; + /** Granular edge mutations (selection, removal). */ edgesChange: [changes: EdgeChange[]]; + /** A connection gesture completed between two handles. */ connect: [connection: Connection]; + /** A connection gesture started from a handle. */ connectStart: [payload: { nodeId: string; handleId: string | null; handleType: HandleType }]; + /** The connection gesture ended, successfully or not. */ connectEnd: []; + /** A node drag finished; ids of every node that moved. */ nodeDragStop: [ids: string[]]; + /** The set of selected nodes/edges changed. */ selectionChange: [selection: { nodes: string[]; edges: string[] }]; + /** A click landed on the empty pane — not on a node or an edge. */ paneClick: [event: PointerEvent]; + /** A settled click on a node (a drag that never started moving). */ nodeClick: [id: string, event: PointerEvent]; + /** Two settled clicks on the same node within the double-click interval. */ nodeDoubleClick: [id: string, event: PointerEvent]; + /** A click on an edge path. */ edgeClick: [id: string, event: PointerEvent]; } @@ -145,6 +156,7 @@ const flowId = useId(undefined, 'flow').value; // ── models (controlled + uncontrolled) ──────────────────────────────────── const localNodes = shallowRef(defaultNodes ? defaultNodes.slice() : []); +/** Current nodes (controlled `v-model:nodes` or internal state). */ const nodes = defineModel('nodes', { get: external => external ?? localNodes.value, set: (value) => { @@ -154,6 +166,7 @@ const nodes = defineModel('nodes', { }); const localEdges = shallowRef(defaultEdges ? defaultEdges.slice() : []); +/** Current edges (controlled `v-model:edges` or internal state). */ const edges = defineModel('edges', { get: external => external ?? localEdges.value, set: (value) => { @@ -163,6 +176,7 @@ const edges = defineModel('edges', { }); const localViewport = shallowRef(defaultViewport ?? { x: 0, y: 0, zoom: 1 }); +/** Current viewport (controlled `v-model:viewport` or internal state). */ const viewport = defineModel('viewport', { get: external => external ?? localViewport.value, set: (value) => { @@ -178,6 +192,7 @@ const viewport = defineModel('viewport', { // would never visually update). ──────────────────────────────────────────── const nodeLookup = shallowRef(new Map()); const edgeLookup = shallowRef(new Map()); +/** Selected node/edge id sets. */ const selection = shallowRef({ nodes: new Set(), edges: new Set() }); const paneRect = shallowRef({ left: 0, top: 0, width: 0, height: 0 }); const isDragging = shallowRef(false); @@ -357,6 +372,7 @@ function emitSelection(): void { emit('selectionChange', { nodes: [...selection.value.nodes], edges: [...selection.value.edges] }); } +/** Select a node — replacing the selection, or adding to it. */ function selectNode(id: string, additive = false): void { if (!elementsSelectable) return; const sel = selection.value; @@ -368,6 +384,7 @@ function selectNode(id: string, additive = false): void { emitSelection(); } +/** Select an edge — replacing the selection, or adding to it. */ function selectEdge(id: string, additive = false): void { if (!elementsSelectable) return; const sel = selection.value; @@ -379,17 +396,20 @@ function selectEdge(id: string, additive = false): void { emitSelection(); } +/** Replace the selection with exactly these nodes and edges. */ function setSelection(nodeIds: string[], edgeIds: string[]): void { selection.value = { nodes: new Set(nodeIds), edges: new Set(edgeIds) }; emitSelection(); } +/** Deselect everything. */ function clearSelection(): void { if (selection.value.nodes.size === 0 && selection.value.edges.size === 0) return; selection.value = { nodes: new Set(), edges: new Set() }; emitSelection(); } +/** Remove every selected node (with its edges) and selected edge. */ function removeSelected(): void { const sel = selection.value; if (sel.nodes.size === 0 && sel.edges.size === 0) return;