161 lines
4.8 KiB
TypeScript
161 lines
4.8 KiB
TypeScript
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<ComponentApiMap> {
|
||
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" });
|
||
}
|
||
}
|
||
};
|
||
}
|