import { existsSync } from "node:fs"; import { resolve } from "node:path"; import { parse } from "vue-docgen-api"; import type { Plugin } from "vite"; import { COMPONENT_LIST } from "../components"; import { createEmptyApi, type ApiEventRow, type ApiExposeRow, type ApiPropRow, type ApiSlotRow, type ComponentApi, type ComponentApiMap } from "../api-types"; /** 虚拟模块的引用标识,业务代码与类型声明共用。 */ export const API_MODULE_ID = "virtual:timi-api"; /** Vite 内部使用的解析后标识,前置 `\0` 避免被其他插件处理。 */ const RESOLVED_ID = `\0${API_MODULE_ID}`; /** * 把 vue-docgen-api 的联合类型描述压平成可读文本。 * 不同版本对 `defineEmits` 的产出结构不一致,这里统一兜底。 */ function normalizeType(type: unknown): string { if (!type || typeof type !== "object") { return "-"; } const candidate = type as { name?: unknown; names?: unknown }; if (Array.isArray(candidate.names)) { return candidate.names.length > 0 ? candidate.names.join(" | ") : "-"; } if (typeof candidate.name === "string" && candidate.name.length > 0) { return candidate.name; } return "-"; } /** 转换 prop 描述符。 */ function toPropRow(descriptor: any): ApiPropRow { return { name: String(descriptor.name ?? ""), type: normalizeType(descriptor.type), defaultValue: descriptor.defaultValue?.value === undefined ? "-" : String(descriptor.defaultValue.value), required: descriptor.required === true, description: String(descriptor.description ?? "") }; } /** 转换事件描述符。 */ function toEventRow(descriptor: any): ApiEventRow { return { name: String(descriptor.name ?? ""), type: normalizeType(descriptor.type), description: String(descriptor.description ?? "") }; } /** 转换插槽描述符,把作用域参数并入说明列。 */ function toSlotRow(descriptor: any): ApiSlotRow { const bindings = Array.isArray(descriptor.bindings) ? descriptor.bindings.map((item: any) => String(item.name ?? "")).filter(Boolean) : []; const description = String(descriptor.description ?? ""); const scope = bindings.length > 0 ? `作用域参数:${bindings.join("、")}` : ""; return { name: String(descriptor.name ?? ""), description: [description, scope].filter(Boolean).join(";") }; } /** 转换暴露描述符。 */ function toExposeRow(descriptor: any): ApiExposeRow { return { name: String(descriptor.name ?? ""), type: normalizeType(descriptor.type), description: String(descriptor.description ?? "") }; } /** 把单个组件文档规范化为纯数据。 */ function toComponentApi(name: string, doc: any): ComponentApi { return { name, description: String(doc?.description ?? ""), parsed: true, props: Array.isArray(doc?.props) ? doc.props.map(toPropRow) : [], events: Array.isArray(doc?.events) ? doc.events.map(toEventRow) : [], slots: Array.isArray(doc?.slots) ? doc.slots.map(toSlotRow) : [], exposes: Array.isArray(doc?.expose) ? doc.expose.map(toExposeRow) : [] }; } /** * 逐个解析组件源码。 * 单个组件解析失败只降级为空 API,不让整个文档站因一处语法而无法构建。 */ async function parseAll(repoRoot: string): Promise { const result: ComponentApiMap = {}; for (const meta of COMPONENT_LIST) { const filePath = resolve(repoRoot, meta.entry); if (!existsSync(filePath)) { console.warn(`[timi-docgen] 未找到组件入口,已跳过:${meta.entry}`); result[meta.name] = createEmptyApi(meta.name); continue; } try { const doc = await parse(filePath); result[meta.name] = toComponentApi(meta.name, doc); } catch (error) { console.warn(`[timi-docgen] 解析 ${meta.name} 失败,已降级为空 API:`, error); result[meta.name] = createEmptyApi(meta.name); } } return result; } /** * 提供 `virtual:timi-api` 虚拟模块,内容为全部组件的 API 数据。 * * 解析结果按需缓存,仅在组件源码变更时失效,避免每次页面请求都重复解析。 * @param repoRoot 仓库根目录的绝对路径 */ export function docgenPlugin(repoRoot: string): Plugin { const entryPathSet = new Set(COMPONENT_LIST.map(meta => resolve(repoRoot, meta.entry))); let cache: ComponentApiMap | null = null; return { name: "timi-docgen", resolveId(id: string) { return id === API_MODULE_ID ? RESOLVED_ID : null; }, async load(id: string) { if (id !== RESOLVED_ID) { return null; } if (cache === null) { cache = await parseAll(repoRoot); } return `export default ${JSON.stringify(cache)};`; }, handleHotUpdate({ file, server }) { if (!entryPathSet.has(file)) { return; } cache = null; const invalidated = server.moduleGraph.getModuleById(RESOLVED_ID); if (invalidated) { server.moduleGraph.invalidateModule(invalidated); server.ws.send({ type: "full-reload" }); } } }; }