add vitepress docs
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
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" });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
/** 虚拟 stub 模块标识。 */
|
||||
const STUB_ID = "\0timi-web-ssr-stub";
|
||||
|
||||
/** 被 stub 的模块说明符。 */
|
||||
const STUBBED_MODULES = new Set(["timi-web", "timi-web/editor"]);
|
||||
|
||||
/**
|
||||
* SSR 阶段必须存在的具名导出。
|
||||
*
|
||||
* 取自 `src` 与 `docs` 中所有 `timi-web` / `timi-web/editor` 值导入的并集,
|
||||
* 是 stub 的保底名单;若构建产物可读,`collectExportNames` 会在此基础上补全全部导出。
|
||||
*/
|
||||
const REQUIRED_EXPORTS = [
|
||||
// ---------- timi-web ----------
|
||||
"VPopup",
|
||||
"Popup",
|
||||
"PopupType",
|
||||
"Copyright",
|
||||
"MarkdownView",
|
||||
"MarkdownEditor",
|
||||
"Toolkit",
|
||||
"WebToolkit",
|
||||
|
||||
// ---------- timi-web/editor ----------
|
||||
"findTextMatches",
|
||||
"replaceAllText",
|
||||
"replaceMatchText",
|
||||
"buildSearchRegex",
|
||||
"highlightCode",
|
||||
"resolveCodeEditorKeydown",
|
||||
"resolvePrismLanguage",
|
||||
"useTiptapEditor",
|
||||
"useTiptapView",
|
||||
"headingMeta",
|
||||
"ATTACHMENT_ID_ATTR",
|
||||
"TEMP_FILE_ID_ATTR",
|
||||
"LOCAL_MEDIA_ID_PREFIX",
|
||||
"isLocalMediaId",
|
||||
"normalizeContent",
|
||||
"supportedCodeLanguages",
|
||||
"TABLE_PICKER_SIZE"
|
||||
];
|
||||
|
||||
/** 从构建产物中提取具名导出,让 stub 自动跟上上游变化。 */
|
||||
function collectExportNames(distFile: string): Set<string> {
|
||||
const names = new Set<string>(REQUIRED_EXPORTS);
|
||||
if (!existsSync(distFile)) {
|
||||
return names;
|
||||
}
|
||||
try {
|
||||
const source = readFileSync(distFile, "utf-8");
|
||||
for (const match of source.matchAll(/export\s*\{([^}]*)\}/g)) {
|
||||
for (const part of match[1].split(",")) {
|
||||
const bits = part.trim().split(/\s+as\s+/);
|
||||
const name = (bits[1] ?? bits[0]).trim();
|
||||
if (/^[A-Za-z_$][\w$]*$/.test(name)) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 读取失败时退回保底名单,不影响构建。
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** 生成 stub 模块源码:每个导出都是可调用、可解构、可当插件安装的空实现。 */
|
||||
function buildStubSource(names: Set<string>): string {
|
||||
const named = [...names]
|
||||
.filter(name => name !== "default")
|
||||
.map(name => `export const ${name} = stub;`)
|
||||
.join("\n");
|
||||
return `const stub = new Proxy(function () {}, {
|
||||
get(target, key) {
|
||||
if (key === Symbol.toPrimitive || key === "valueOf" || key === "toString") {
|
||||
return () => "";
|
||||
}
|
||||
return stub;
|
||||
},
|
||||
apply() {
|
||||
return stub;
|
||||
},
|
||||
construct() {
|
||||
return {};
|
||||
}
|
||||
});
|
||||
export default stub;
|
||||
${named}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 SSR 阶段把 timi-web 替换为空实现。
|
||||
*
|
||||
* timi-web 的构建产物在模块顶层就访问 `window` 并启动 Prism 自动高亮定时器,
|
||||
* 在 Node 中 import 会直接抛错,导致 VitePress 的 SSR 构建必然失败。
|
||||
*
|
||||
* 文档站里真正需要 timi-web 的只有演示组件,它们全部通过 `clientOnly` 延迟到浏览器渲染,
|
||||
* SSR 阶段只需要「能 import 且不报错」,因此这里返回一个 Proxy 空实现。
|
||||
* 客户端构建不受影响,`options.ssr` 为 false 时照常解析到真实模块。
|
||||
* @param docsDir docs 目录绝对路径,用于定位 timi-web 构建产物
|
||||
*/
|
||||
export function timiWebSsrStub(docsDir: string): Plugin {
|
||||
const distFile = resolve(docsDir, "node_modules/timi-web/dist/timi-web.mjs");
|
||||
let stubSource: string | null = null;
|
||||
|
||||
return {
|
||||
name: "timi-web-ssr-stub",
|
||||
enforce: "pre",
|
||||
|
||||
resolveId(source, importer, options) {
|
||||
// 只拦截精确的包名,`timi-web/style.css` 等子路径照常交给 Vite 处理。
|
||||
if (options?.ssr && STUBBED_MODULES.has(source)) {
|
||||
return STUB_ID;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
load(id) {
|
||||
if (id !== STUB_ID) {
|
||||
return null;
|
||||
}
|
||||
if (stubSource === null) {
|
||||
stubSource = buildStubSource(collectExportNames(distFile));
|
||||
}
|
||||
return stubSource;
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user