add vitepress docs
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* API 文档数据类型。
|
||||
*
|
||||
* 这些结构由 Node 侧的 `plugins/docgen.ts` 产出,通过虚拟模块交给浏览器渲染,
|
||||
* 因此刻意保持为不含第三方类型的纯数据,避免 `vue-docgen-api` 被打进客户端产物。
|
||||
*/
|
||||
|
||||
/** 单个 prop 的表格行数据。 */
|
||||
export interface ApiPropRow {
|
||||
/** prop 名称。 */
|
||||
name: string;
|
||||
/** 类型签名,如 `string`、`Article`。 */
|
||||
type: string;
|
||||
/** 默认值,未声明时为空字符串。 */
|
||||
defaultValue: string;
|
||||
/** 是否必填。 */
|
||||
required: boolean;
|
||||
/** 注释说明,取自源码 JSDoc。 */
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** 单个事件的表格行数据。 */
|
||||
export interface ApiEventRow {
|
||||
/** 事件名,如 `update:modelValue`。 */
|
||||
name: string;
|
||||
/** 回调参数签名。 */
|
||||
type: string;
|
||||
/** 注释说明。 */
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** 单个插槽的表格行数据。 */
|
||||
export interface ApiSlotRow {
|
||||
/** 插槽名。 */
|
||||
name: string;
|
||||
/** 注释说明或作用域参数。 */
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** 单个暴露方法或属性的表格行数据。 */
|
||||
export interface ApiExposeRow {
|
||||
/** 暴露的名称。 */
|
||||
name: string;
|
||||
/** 类型签名。 */
|
||||
type: string;
|
||||
/** 注释说明。 */
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** 一个组件解析完成后的完整 API 数据。 */
|
||||
export interface ComponentApi {
|
||||
/** 组件名。 */
|
||||
name: string;
|
||||
/** 组件自身的注释说明。 */
|
||||
description: string;
|
||||
/**
|
||||
* 源码是否解析成功。
|
||||
*
|
||||
* 用来区分「解析失败」与「组件本身就没有 props/events/slots/expose」,
|
||||
* 两者都会得到空数组,但提示语完全不同。
|
||||
*/
|
||||
parsed: boolean;
|
||||
props: ApiPropRow[];
|
||||
events: ApiEventRow[];
|
||||
slots: ApiSlotRow[];
|
||||
exposes: ApiExposeRow[];
|
||||
}
|
||||
|
||||
/** 全部组件的 API 数据,键为组件名。 */
|
||||
export type ComponentApiMap = Record<string, ComponentApi>;
|
||||
|
||||
/** 生成一个空的 API 数据,用于解析失败时兜底,保证文档站不因单个组件而白屏。 */
|
||||
export function createEmptyApi(name: string): ComponentApi {
|
||||
return {
|
||||
name,
|
||||
description: "",
|
||||
parsed: false,
|
||||
props: [],
|
||||
events: [],
|
||||
slots: [],
|
||||
exposes: []
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 组件元数据清单。
|
||||
*
|
||||
* 这是文档站的唯一数据源,三处消费它:
|
||||
* - `config.ts` 生成侧边栏导航
|
||||
* - `plugins/docgen.ts` 按 `entry` 定位源码并交给 vue-docgen-api 解析
|
||||
* - 页面内的 `<api-table />` 按 `name` 查询解析结果
|
||||
*
|
||||
* 新增组件时只需在此追加一项,导航、API 表、文档路由会同步生效。
|
||||
*/
|
||||
|
||||
/** 分类展示顺序即数组顺序。 */
|
||||
export const CATEGORY_LIST = [
|
||||
"布局组件",
|
||||
"编辑器组件",
|
||||
"交互组件",
|
||||
"AI 对话组件"
|
||||
] as const;
|
||||
|
||||
export type ComponentCategory = (typeof CATEGORY_LIST)[number];
|
||||
|
||||
export interface ComponentMeta {
|
||||
/** 组件名,与组件内 `defineOptions` 的 name 一致,同时是 api-table 的查询键。 */
|
||||
name: string;
|
||||
/** 文档与侧边栏展示的中文标题。 */
|
||||
title: string;
|
||||
/** 一句话功能描述。 */
|
||||
description: string;
|
||||
/** 所属分类。 */
|
||||
category: ComponentCategory;
|
||||
/** 组件入口源码,相对仓库根目录。 */
|
||||
entry: string;
|
||||
/** 文档路径覆盖,默认由文件名或目录名推导。 */
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
export const COMPONENT_LIST: ComponentMeta[] = [
|
||||
// ---------- 布局组件 ----------
|
||||
{
|
||||
name: "RootLayout",
|
||||
title: "RootLayout 根布局",
|
||||
description: "移动端页面骨架,主体内容之外自动渲染版权与备案信息。",
|
||||
category: "布局组件",
|
||||
entry: "src/components/root-layout/index.vue"
|
||||
},
|
||||
{
|
||||
name: "Space",
|
||||
title: "Space 间距",
|
||||
description: "控制子元素之间的间距与排列方向,支持分隔符。",
|
||||
category: "布局组件",
|
||||
entry: "src/components/space/index.vue"
|
||||
},
|
||||
|
||||
// ---------- 编辑器组件 ----------
|
||||
{
|
||||
name: "CodeEditor",
|
||||
title: "CodeEditor 代码编辑器",
|
||||
description: "带行号、语法高亮与查找替换的代码输入框。",
|
||||
category: "编辑器组件",
|
||||
entry: "src/components/code-editor/index.vue"
|
||||
},
|
||||
{
|
||||
name: "TiptapEditor",
|
||||
title: "TiptapEditor 富文本编辑器",
|
||||
description: "基于 Tiptap 的富文本编辑器,工具栏覆盖媒体、表格、链接与代码块。",
|
||||
category: "编辑器组件",
|
||||
entry: "src/components/tiptap-editor/index.vue"
|
||||
},
|
||||
{
|
||||
name: "TiptapView",
|
||||
title: "TiptapView 富文本视图",
|
||||
description: "Tiptap 内容的只读渲染视图,与编辑器共用同一套样式。",
|
||||
category: "编辑器组件",
|
||||
entry: "src/components/tiptap-view/index.vue"
|
||||
},
|
||||
{
|
||||
name: "ArticleEditor",
|
||||
title: "ArticleEditor 文章编辑器",
|
||||
description: "以 Article 为数据模型,按正文类型切换纯文本、Markdown 与富文本编辑。",
|
||||
category: "编辑器组件",
|
||||
entry: "src/components/article-editor/index.vue"
|
||||
},
|
||||
{
|
||||
name: "ArticleView",
|
||||
title: "ArticleView 文章视图",
|
||||
description: "按 Article 的正文类型渲染正文与附件列表。",
|
||||
category: "编辑器组件",
|
||||
entry: "src/components/article-view/index.vue"
|
||||
},
|
||||
{
|
||||
name: "NotifyEditor",
|
||||
title: "NotifyEditor 通知编辑器",
|
||||
description: "以 NotifyDetail 为数据模型的通知编辑表单,复用文章编辑器能力。",
|
||||
category: "编辑器组件",
|
||||
entry: "src/components/notify-editor/index.vue"
|
||||
},
|
||||
{
|
||||
name: "NotifyView",
|
||||
title: "NotifyView 通知视图",
|
||||
description: "渲染通知的正文内容。",
|
||||
category: "编辑器组件",
|
||||
entry: "src/components/notify-view/index.vue"
|
||||
},
|
||||
|
||||
// ---------- 交互组件 ----------
|
||||
{
|
||||
name: "BottomPopup",
|
||||
title: "BottomPopup 底部弹层",
|
||||
description: "从底部升起的操作面板,可带标题与左右按钮。",
|
||||
category: "交互组件",
|
||||
entry: "src/components/bottom-popup/index.vue"
|
||||
},
|
||||
{
|
||||
name: "DurationPicker",
|
||||
title: "DurationPicker 时长选择",
|
||||
description: "按时长值选择并回填,单位与候选项由外部决定。",
|
||||
category: "交互组件",
|
||||
entry: "src/components/duration-picker/index.vue"
|
||||
},
|
||||
|
||||
// ---------- AI 对话组件 ----------
|
||||
{
|
||||
name: "AiChatPanel",
|
||||
title: "AiChatPanel 对话面板",
|
||||
description: "把消息列表与输入框组装成完整对话界面,由业务侧驱动数据。",
|
||||
category: "AI 对话组件",
|
||||
entry: "src/components/ai/AiChatPanel.vue"
|
||||
},
|
||||
{
|
||||
name: "AiChatMessageList",
|
||||
title: "AiChatMessageList 消息列表",
|
||||
description: "渲染对话消息,含思考过程、引用与流式光标。",
|
||||
category: "AI 对话组件",
|
||||
entry: "src/components/ai/AiChatMessageList.vue"
|
||||
},
|
||||
{
|
||||
name: "AiChatComposer",
|
||||
title: "AiChatComposer 输入框",
|
||||
description: "对话输入框,按状态在发送、停止与重试之间切换。",
|
||||
category: "AI 对话组件",
|
||||
entry: "src/components/ai/AiChatComposer.vue"
|
||||
},
|
||||
{
|
||||
name: "AiChatThinking",
|
||||
title: "AiChatThinking 思考过程",
|
||||
description: "折叠展示模型的思考分段,生成中自动展开。",
|
||||
category: "AI 对话组件",
|
||||
entry: "src/components/ai/AiChatThinking.vue"
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* 文档路径与路由片段。
|
||||
*
|
||||
* `index.vue` 取所在目录名,具名文件取文件名——`ai` 目录下有多个组件,
|
||||
* 只靠目录名会全部撞成 `ai`。两种写法都会再转成短横线形式。
|
||||
*/
|
||||
export function toSlug(meta: ComponentMeta): string {
|
||||
if (meta.slug) {
|
||||
return meta.slug;
|
||||
}
|
||||
const segments = meta.entry.split("/");
|
||||
const fileName = segments[segments.length - 1];
|
||||
const baseName = fileName.replace(/\.\w+$/, "");
|
||||
const raw = baseName === "index" ? segments[segments.length - 2] : baseName;
|
||||
return raw.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
||||
}
|
||||
|
||||
/** 组件文档页的站内绝对路径。 */
|
||||
export function toDocLink(meta: ComponentMeta): string {
|
||||
return `/components/${toSlug(meta)}`;
|
||||
}
|
||||
|
||||
/** 按分类分组,保持 CATEGORY_LIST 的声明顺序。 */
|
||||
export function groupByCategory(): Array<{ category: ComponentCategory; items: ComponentMeta[] }> {
|
||||
return CATEGORY_LIST.map(category => ({
|
||||
category,
|
||||
items: COMPONENT_LIST.filter(item => item.category === category)
|
||||
})).filter(group => group.items.length > 0);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { resolve } from "node:path";
|
||||
import { defineConfig } from "vitepress";
|
||||
import {
|
||||
componentPreview,
|
||||
containerPreview
|
||||
} from "@vitepress-demo-preview/plugin";
|
||||
import AutoImport from "unplugin-auto-import/vite";
|
||||
import { groupByCategory, toDocLink } from "./components";
|
||||
import { docgenPlugin } from "./plugins/docgen";
|
||||
import { timiWebSsrStub } from "./plugins/ssr-stub";
|
||||
|
||||
/** `__dirname` 由 VitePress 打包配置时注入,指向 `docs/.vitepress`。 */
|
||||
const docsDir = resolve(__dirname, "..");
|
||||
/** 仓库根目录,组件源码位于它的 `src` 下。 */
|
||||
const repoRoot = resolve(docsDir, "..");
|
||||
/** timi-web 仓库目录,docs 通过 link 依赖它。 */
|
||||
const timiWebRoot = resolve(repoRoot, "../timi-web");
|
||||
|
||||
/**
|
||||
* 源码引用别名。
|
||||
*
|
||||
* demo 直接引用 `src` 源码而非构建产物,改组件即可热更新文档。
|
||||
* 组件源码自身的传递依赖(tiptap、prismjs 等)由 timi-web 的 node_modules 解析,
|
||||
* docs 无需重复声明。
|
||||
*/
|
||||
const alias = {
|
||||
"timi-tdesign-mobile": resolve(repoRoot, "src/index.ts"),
|
||||
"timi-web/editor": resolve(timiWebRoot, "src/editor/index.ts")
|
||||
};
|
||||
|
||||
/** 组件文档侧边栏,按 components.ts 的分类与顺序生成。 */
|
||||
const componentSidebar = groupByCategory().map(group => ({
|
||||
text: group.category,
|
||||
collapsed: false,
|
||||
items: group.items.map(item => ({
|
||||
text: item.title,
|
||||
link: toDocLink(item)
|
||||
}))
|
||||
}));
|
||||
|
||||
export default defineConfig({
|
||||
title: "timi-tdesign-mobile",
|
||||
description: "移动端 TDesign 组件库的演示与 API 文档",
|
||||
lang: "zh-CN",
|
||||
cleanUrls: true,
|
||||
|
||||
themeConfig: {
|
||||
nav: [
|
||||
{ text: "指南", link: "/guide/" },
|
||||
{ text: "场景", link: "/scenarios/editor" },
|
||||
{ text: "组件", link: "/components/" }
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
text: "指南",
|
||||
items: [
|
||||
{ text: "快速开始", link: "/guide/" },
|
||||
{ text: "演示与文档约定", link: "/guide/demo" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "场景示例",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: "编辑器能力总览", link: "/scenarios/editor" },
|
||||
{ text: "文章编辑与预览", link: "/scenarios/article" },
|
||||
{ text: "通知编辑与预览", link: "/scenarios/notify" },
|
||||
{ text: "移动端组件", link: "/scenarios/mobile" },
|
||||
{ text: "AI 对话", link: "/scenarios/ai" }
|
||||
]
|
||||
},
|
||||
...componentSidebar
|
||||
],
|
||||
outline: {
|
||||
level: [2, 3],
|
||||
label: "本页目录"
|
||||
},
|
||||
search: {
|
||||
provider: "local"
|
||||
},
|
||||
docFooter: {
|
||||
prev: "上一篇",
|
||||
next: "下一篇"
|
||||
},
|
||||
darkModeSwitchLabel: "主题",
|
||||
sidebarMenuLabel: "目录",
|
||||
returnToTopLabel: "回到顶部"
|
||||
},
|
||||
|
||||
markdown: {
|
||||
theme: {
|
||||
light: "vitesse-light",
|
||||
dark: "vitesse-dark"
|
||||
},
|
||||
lineNumbers: true,
|
||||
config(md) {
|
||||
// 组件依赖浏览器 API(Tiptap、Prism、ResizeObserver、SSE 等),
|
||||
// 统一开启 clientOnly 交给浏览器渲染,避免 SSR 阶段报错。
|
||||
md.use(componentPreview, { clientOnly: true, alias });
|
||||
md.use(containerPreview, { clientOnly: true, alias });
|
||||
}
|
||||
},
|
||||
|
||||
vite: {
|
||||
resolve: {
|
||||
alias
|
||||
},
|
||||
|
||||
plugins: [
|
||||
// 必须最先执行:SSR 阶段拦截 timi-web,否则整个构建无法通过。
|
||||
timiWebSsrStub(docsDir),
|
||||
docgenPlugin(repoRoot),
|
||||
// src 源码依赖自动导入(内部没有显式 import vue 的文件不在少数),
|
||||
// 这里仅为其提供支持;docs 自身的代码一律显式导入。
|
||||
AutoImport({
|
||||
imports: ["vue"],
|
||||
dts: false
|
||||
})
|
||||
],
|
||||
|
||||
server: {
|
||||
port: 3004,
|
||||
host: true,
|
||||
fs: {
|
||||
// demo 需要读取 docs 之外的 src 与 timi-web 源码。
|
||||
allow: [repoRoot, timiWebRoot]
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div v-if="api" class="tui-api">
|
||||
<section v-if="api.props.length > 0">
|
||||
<h3>Props</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>类型</th>
|
||||
<th>默认值</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in api.props" :key="row.name">
|
||||
<td class="name">
|
||||
<code v-text="row.name" />
|
||||
<span v-if="row.required" class="required" title="必填">*</span>
|
||||
</td>
|
||||
<td><code v-text="row.type" /></td>
|
||||
<td><code v-text="row.defaultValue" /></td>
|
||||
<td v-text="row.description" />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section v-if="api.events.length > 0">
|
||||
<h3>Events</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>参数</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in api.events" :key="row.name">
|
||||
<td><code v-text="row.name" /></td>
|
||||
<td><code v-text="row.type" /></td>
|
||||
<td v-text="row.description" />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section v-if="api.slots.length > 0">
|
||||
<h3>Slots</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in api.slots" :key="row.name">
|
||||
<td><code v-text="row.name" /></td>
|
||||
<td v-text="row.description" />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section v-if="api.exposes.length > 0">
|
||||
<h3>Expose</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>类型</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in api.exposes" :key="row.name">
|
||||
<td><code v-text="row.name" /></td>
|
||||
<td><code v-text="row.type" /></td>
|
||||
<td v-text="row.description" />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<p v-if="isEmpty && !api.parsed" class="notice warn">
|
||||
未能从源码中解析出 API 信息,请检查组件是否使用了 defineProps / defineEmits / defineExpose。
|
||||
</p>
|
||||
<p v-else-if="isEmpty" class="notice">
|
||||
该组件不需要任何 props、events、slots 或实例方法。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p v-else class="notice warn" v-text="`未找到组件 ${name} 的 API 数据,请检查 components.ts 中的 name 与 entry 是否正确`" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import apiMap from "virtual:timi-api";
|
||||
|
||||
/**
|
||||
* 组件 API 表格。
|
||||
*
|
||||
* 数据来自构建期 `vue-docgen-api` 对组件源码的解析结果,
|
||||
* 无需在 markdown 中手写 props / events / slots 表格。
|
||||
* 某个分区没有内容时整段不渲染,避免出现空表。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** 组件名,需与 components.ts 中的 name 一致。 */
|
||||
name: string;
|
||||
}>();
|
||||
|
||||
const api = computed(() => apiMap[props.name]);
|
||||
|
||||
const isEmpty = computed(() => {
|
||||
if (!api.value) {
|
||||
return false;
|
||||
}
|
||||
const { props: propRows, events, slots, exposes } = api.value;
|
||||
return propRows.length === 0 && events.length === 0 && slots.length === 0 && exposes.length === 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.tui-api {
|
||||
section {
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 1.5rem 0 .75rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.name {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.required {
|
||||
margin-left: .125rem;
|
||||
color: var(--vp-c-danger-1);
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 1rem 0;
|
||||
padding: .75rem 1rem;
|
||||
font-size: .875rem;
|
||||
border-radius: .5rem;
|
||||
color: var(--vp-c-text-2);
|
||||
background: var(--vp-c-bg-soft);
|
||||
|
||||
&.warn {
|
||||
color: var(--vp-c-warning-1);
|
||||
background: var(--vp-c-warning-soft);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<div class="tui-demo">
|
||||
<section class="stage">
|
||||
<div class="viewport">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="meta">
|
||||
<div class="info">
|
||||
<p v-if="title" class="title" v-text="title" />
|
||||
<p v-if="description" class="desc" v-text="description" />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="action"
|
||||
type="button"
|
||||
@click="handleCopy"
|
||||
v-text="copied ? '已复制' : '复制代码'"
|
||||
/>
|
||||
<button
|
||||
class="action"
|
||||
type="button"
|
||||
@click="toggleExpanded"
|
||||
v-text="expanded ? '收起代码' : '展开代码'"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-show="expanded" class="source">
|
||||
<div class="language-vue" v-html="sourceHtml" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onUnmounted, ref } from "vue";
|
||||
|
||||
/**
|
||||
* 预览容器。
|
||||
*
|
||||
* props 契约由 `@vitepress-demo-preview/plugin` 决定,它把 markdown 里的
|
||||
* `:::preview` 容器编译成 `<demo-preview>` 标签并传入下列属性:
|
||||
* - `code`:`encodeURI` 处理过的组件源码
|
||||
* - `showCode`:`encodeURIComponent` 处理过的高亮 HTML
|
||||
*
|
||||
* 这里刻意不依赖官方容器,也不引入额外 UI 库,只用原生元素配合 VitePress 的
|
||||
* CSS 变量,深色模式随之自动适配。
|
||||
*
|
||||
* 演示统一放在手机宽度的视口里:这些是移动端组件,按桌面宽度铺开会失去真实形态。
|
||||
* 需要调整宽度时改 `--tui-demo-viewport` 即可。
|
||||
*/
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 源码原文,经 encodeURI 编码。 */
|
||||
code?: string;
|
||||
/** 高亮后的源码 HTML,经 encodeURIComponent 编码。 */
|
||||
showCode?: string;
|
||||
/** 演示标题,来自 `:::preview` 后方第一段文字。 */
|
||||
title?: string;
|
||||
/** 演示描述,来自 `:::preview` 中 `||` 之后的内容。 */
|
||||
description?: string;
|
||||
}>(), {
|
||||
code: "",
|
||||
showCode: "",
|
||||
title: "",
|
||||
description: ""
|
||||
});
|
||||
|
||||
/** 源码区是否展开,默认收起以保持文档阅读节奏。 */
|
||||
const expanded = ref(false);
|
||||
/** 复制成功后的短暂反馈。 */
|
||||
const copied = ref(false);
|
||||
let copiedTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const sourceHtml = computed(() => decodeURIComponent(props.showCode));
|
||||
const sourceText = computed(() => decodeURIComponent(props.code));
|
||||
|
||||
/** 复制源码到剪贴板,clipboard 不可用时保持原按钮文案。 */
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(sourceText.value);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
copied.value = true;
|
||||
if (copiedTimer !== null) {
|
||||
clearTimeout(copiedTimer);
|
||||
}
|
||||
copiedTimer = setTimeout(() => {
|
||||
copied.value = false;
|
||||
copiedTimer = null;
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function toggleExpanded() {
|
||||
expanded.value = !expanded.value;
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (copiedTimer !== null) {
|
||||
clearTimeout(copiedTimer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.tui-demo {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .75rem;
|
||||
background: var(--vp-c-bg);
|
||||
|
||||
.stage {
|
||||
display: flex;
|
||||
padding: 1.5rem;
|
||||
justify-content: center;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
// 手机视口:移动组件在桌面宽度下会被拉伸,这里统一收到移动端宽度
|
||||
.viewport {
|
||||
width: 100%;
|
||||
max-width: var(--tui-demo-viewport, 26rem);
|
||||
padding: 1rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .75rem;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: .75rem 1.5rem;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: .875rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin: .25rem 0 0;
|
||||
font-size: .75rem;
|
||||
line-height: 1.5;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: .25rem;
|
||||
}
|
||||
|
||||
.action {
|
||||
padding: .25rem .5rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: .25rem;
|
||||
color: var(--vp-c-text-2);
|
||||
background: transparent;
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-default-soft);
|
||||
}
|
||||
}
|
||||
|
||||
.source {
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
|
||||
:deep(div[class*="language-"]) {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.tui-demo {
|
||||
.stage {
|
||||
padding: .75rem;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
padding: .5rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
padding: .75rem 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
/// <reference types="vitepress/client" />
|
||||
|
||||
/** 构建期由 vue-docgen-api 解析生成的组件 API 数据。 */
|
||||
declare module "virtual:timi-api" {
|
||||
import type { ComponentApiMap } from "../api-types";
|
||||
|
||||
const apiMap: ComponentApiMap;
|
||||
export default apiMap;
|
||||
}
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
|
||||
export default component;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import DefaultTheme from "vitepress/theme";
|
||||
import { watch } from "vue";
|
||||
import { useData } from "vitepress";
|
||||
import type { App } from "vue";
|
||||
import TDesignMobile from "tdesign-mobile-vue";
|
||||
import TimiWebUI, { VPopup } from "timi-web";
|
||||
|
||||
// 必须引完整样式:`tdesign-mobile-vue/es/style/index.css` 只含全局 token,
|
||||
// 不含任何组件类,全量注册时会导致所有组件无样式渲染。
|
||||
import "tdesign-mobile-vue/dist/tdesign.css";
|
||||
import "./styles/index.less";
|
||||
|
||||
import DemoPreview from "./containers/DemoPreview.vue";
|
||||
import ApiTable from "./components/ApiTable.vue";
|
||||
|
||||
/**
|
||||
* 同步深色模式。
|
||||
*
|
||||
* VitePress 通过 `html.dark` 切换深色,而 TDesign 依赖 `html[theme-mode="dark"]`,
|
||||
* 两者不是同一套机制,不同步会导致深色模式下 TDesign 组件仍是亮色。
|
||||
*/
|
||||
function syncThemeMode(dark: boolean) {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
document.documentElement.setAttribute("theme-mode", dark ? "dark" : "light");
|
||||
}
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
|
||||
enhanceApp({ app }: { app: App }) {
|
||||
// 全量注册 TDesign Mobile,演示区内所有 t-* 组件直接可用。
|
||||
app.use(TDesignMobile);
|
||||
// 组件内部大量使用 timi-web 的 v-popup 指令,且用在组件模板里,
|
||||
// 无法由演示局部注入,只能在主题里全局注册。
|
||||
app.use(TimiWebUI);
|
||||
app.directive("popup", VPopup);
|
||||
|
||||
app.component("demo-preview", DemoPreview);
|
||||
app.component("api-table", ApiTable);
|
||||
},
|
||||
|
||||
setup() {
|
||||
const { isDark } = useData();
|
||||
watch(isDark, syncThemeMode, { immediate: true });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 文档站全局样式。
|
||||
*
|
||||
* 只处理 VitePress 正文与演示容器、API 表格的交界处;
|
||||
* 组件自身的样式一律各自 scoped,避免相互污染。
|
||||
*/
|
||||
|
||||
.vp-doc {
|
||||
// 演示容器与正文的呼吸感
|
||||
.tui-demo {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
// API 表格里的类型与默认值不换行,避免长签名把列挤变形
|
||||
table code {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user