@antdv-next/docs-plugins 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -0
  3. package/dist/demo/formatter.d.ts +17 -0
  4. package/dist/demo/formatter.js +32 -0
  5. package/dist/demo/get-demo-id.d.ts +9 -0
  6. package/dist/demo/get-demo-id.js +19 -0
  7. package/dist/demo/index.d.ts +26 -0
  8. package/dist/demo/index.js +359 -0
  9. package/dist/demo/tsToJs.d.ts +9 -0
  10. package/dist/demo/tsToJs.js +60 -0
  11. package/dist/demo/types.d.ts +23 -0
  12. package/dist/index.d.ts +20 -0
  13. package/dist/index.js +19 -0
  14. package/dist/isolate-styles.d.ts +14 -0
  15. package/dist/isolate-styles.js +30 -0
  16. package/dist/markdown.d.ts +39 -0
  17. package/dist/markdown.js +115 -0
  18. package/dist/md-plugin.d.ts +18 -0
  19. package/dist/md-plugin.js +9 -0
  20. package/dist/md2vue.d.ts +16 -0
  21. package/dist/md2vue.js +106 -0
  22. package/dist/plugins/container.d.ts +16 -0
  23. package/dist/plugins/container.js +53 -0
  24. package/dist/plugins/demo.d.ts +29 -0
  25. package/dist/plugins/demo.js +139 -0
  26. package/dist/plugins/github-alerts.d.ts +6 -0
  27. package/dist/plugins/github-alerts.js +49 -0
  28. package/dist/plugins/image.d.ts +12 -0
  29. package/dist/plugins/image.js +17 -0
  30. package/dist/plugins/link.d.ts +14 -0
  31. package/dist/plugins/link.js +25 -0
  32. package/dist/plugins/pre-wrapper.d.ts +10 -0
  33. package/dist/plugins/pre-wrapper.js +26 -0
  34. package/dist/plugins/stackblitz.d.ts +5 -0
  35. package/dist/plugins/stackblitz.js +21 -0
  36. package/dist/plugins/table.d.ts +5 -0
  37. package/dist/plugins/table.js +25 -0
  38. package/dist/shared.d.ts +7 -0
  39. package/dist/shared.js +7 -0
  40. package/dist/utils/short-hash.d.ts +4 -0
  41. package/dist/utils/short-hash.js +21 -0
  42. package/package.json +94 -0
  43. package/src/components/code-demo/code-editor-bridge.vue +36 -0
  44. package/src/components/code-demo/compile-sfc.ts +207 -0
  45. package/src/components/code-demo/context.ts +86 -0
  46. package/src/components/code-demo/expand-icon.vue +12 -0
  47. package/src/components/code-demo/external-link-icon.vue +5 -0
  48. package/src/components/code-demo/index.vue +682 -0
  49. package/src/components/code-demo/virtual.d.ts +38 -0
  50. package/src/demo/formatter.ts +50 -0
  51. package/src/demo/get-demo-id.ts +33 -0
  52. package/src/demo/index.ts +498 -0
  53. package/src/demo/tsToJs.ts +79 -0
  54. package/src/demo/types.ts +23 -0
  55. package/src/index.ts +19 -0
  56. package/src/isolate-styles.ts +47 -0
  57. package/src/markdown.ts +188 -0
  58. package/src/md-plugin.ts +24 -0
  59. package/src/md2vue.ts +163 -0
  60. package/src/plugins/container.ts +135 -0
  61. package/src/plugins/demo.ts +282 -0
  62. package/src/plugins/github-alerts.ts +69 -0
  63. package/src/plugins/image.ts +29 -0
  64. package/src/plugins/link.ts +32 -0
  65. package/src/plugins/pre-wrapper.ts +49 -0
  66. package/src/plugins/stackblitz.ts +32 -0
  67. package/src/plugins/table.ts +39 -0
  68. package/src/shared.ts +4 -0
  69. package/src/utils/short-hash.ts +27 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Antdv Next
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # @antdv-next/docs-plugins
2
+
3
+ [antdv-next/docs-base](https://github.com/antdv-next/docs-base) 文档站点的插件与组件集:一条开箱即用的 Vite + markdown-it 管线,负责 Markdown → Vue 渲染、demo 收集、代码高亮与在线编辑,并附带可复用的 `<CodeDemo>` 展示组件。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ pnpm add -D @antdv-next/docs-plugins
9
+ ```
10
+
11
+ ## 快速开始
12
+
13
+ ### 1. 接入 markdown 管线
14
+
15
+ ```ts
16
+ // vite.config.ts
17
+ import { mdPlugin } from '@antdv-next/docs-plugins'
18
+
19
+ export default defineConfig({
20
+ plugins: [
21
+ mdPlugin(),
22
+ ],
23
+ })
24
+ ```
25
+
26
+ `mdPlugin()` = `md2VuePlugin`(Markdown → Vue SFC)+ `demoPlugin`(demo 收集虚拟模块),覆盖:
27
+
28
+ - shiki 双主题高亮、anchor/toc/frontmatter/emoji、tip/warning 等容器、GitHub Alerts
29
+ - demo 收集:页面里 `<demo src="./demo/basic.vue">标题</demo>` 生成锚点目录与按需加载的 demo 模块
30
+ - 外部链接自动新标签页打开
31
+
32
+ ### 2. 编写文档与 demo
33
+
34
+ ```md
35
+ ## 代码演示 {#examples} <!-- 英文页写 ## Examples -->
36
+
37
+ <demo src="./demo/basic.vue">基本用法</demo>
38
+ ```
39
+
40
+ - demo 文件放在页面同级的 `demo/` 目录(默认收集 `<root>/src/pages/**/demo/*.vue`,可用 `include` 调整)
41
+ - demo SFC 内的 `<docs>` 自定义块作为该 demo 的描述文案
42
+ - 锚点统一挂到 slug 为 `examples` 的标题下
43
+
44
+ ### 3. 隔离 markdown 样式(可选)
45
+
46
+ ```ts
47
+ css: {
48
+ postcss: {
49
+ plugins: [postcssIsolateStyles()],
50
+ },
51
+ },
52
+ ```
53
+
54
+ ## `<CodeDemo>` 组件(自定义 demo 渲染时使用)
55
+
56
+ demo 的展示与在线编辑由包内组件承担,站点只需注入站点差异:
57
+
58
+ ```ts
59
+ import CodeDemo from '@antdv-next/docs-plugins/component/code-demo/index.vue'
60
+ import { provideDemoContext } from '@antdv-next/docs-plugins/component/code-demo/context'
61
+
62
+ provideDemoContext({
63
+ locale: () => (route.meta?.locale === 'en-US' ? 'en-US' : 'zh-CN'),
64
+ isDark: () => isDark.value,
65
+ // 演示源码可 import 的模块(浏览器实时编译用),逐模块容错
66
+ modules: {
67
+ 'antdv-next': () => import('antdv-next'),
68
+ '@antdv-next/icons': () => import('@antdv-next/icons'),
69
+ },
70
+ openPlayground: code => window.open(loadPlaygroundUrl(code), '_blank'),
71
+ // openStackBlitz / demoPageUrl / iframeRenderer / preferredCodeType 可选
72
+ })
73
+ ```
74
+
75
+ ```vue
76
+ <CodeDemo src="/src/pages/components/button/demo/basic.vue">
77
+ 基本用法
78
+ </CodeDemo>
79
+ ```
80
+
81
+ - 展开代码面板即得可编辑编辑器(Sandpack),编辑内容经 `@vue/compiler-sfc` + `sucrase` 在浏览器端实时编译并替换预览
82
+ - 未提供的能力(如 StackBlitz)对应按钮自动隐藏
83
+
84
+ 建议在 vite 中对这些依赖做 `resolve.dedupe`,保证与站点共享同一实例:
85
+
86
+ ```ts
87
+ resolve: {
88
+ dedupe: ['vue', 'vue-router', '@vueuse/core', 'antdv-next', '@antdv-next/icons', 'antdv-style'],
89
+ },
90
+ ```
91
+
92
+ ### virtual:demos 类型声明
93
+
94
+ 自定义 demo UI 时引用包内自带的类型:
95
+
96
+ ```ts
97
+ /// <reference types="@antdv-next/docs-plugins/component/code-demo/virtual" />
98
+ ```
99
+
100
+ ## 默认行为与选项
101
+
102
+ | 选项 | 默认 | 说明 |
103
+ | --- | --- | --- |
104
+ | `markdown.demo.headerMode` | `'examples'` | demo 锚点挂到 slug 为 `examples` 的标题下;`'section'` 为跟随章节层级 |
105
+ | `markdown.demo.debugDemo` | `true` | 生产构建跳过带 `debug` 属性 demo 的目录收集 |
106
+ | `markdown.demo.wrapper` | `'demo'` | demo 标签名 |
107
+ | `markdown.externalLink` | `true` | 外部链接自动 `target="_blank"` + `rel="noopener noreferrer"` |
108
+ | `demo.include` | `/src/pages/**/demo/*.vue` | demo 收集范围 |
109
+ | `demo.sourceVersionOnHmr` | `true` | 任意 HMR 提升 sourceVersion,促使已挂载 demo 重新拉取源码 |
110
+ | `demo.jsFormatter` | 内置 oxfmt | TS → JS 源码格式化器,可用 `createOxfmtJsFormatter(style)` 换风格 |
111
+ | ~~`demo.renderSourceHtml` / `demo.virtualMode`~~ | 已移除 | 源码统一在浏览器端编译展示;`virtual:demos` 统一为懒加载注册表 |
112
+
113
+ ## API 总览
114
+
115
+ - `mdPlugin(options)` / `md2VuePlugin` / `demoPlugin`
116
+ - `createMarkdown` / `useMarkdown` / `loadBaseMd` / `loadShiki`(`CreateMarkdownOptions`)
117
+ - markdown-it 插件:`container` / `demo` / `github-alerts` / `image` / `link` / `pre-wrapper` / `stackblitz` / `table`
118
+ - `postcssIsolateStyles`:markdown 样式隔离 PostCSS 插件
119
+ - `tsToJs` + `createOxfmtJsFormatter`:demo 源码 TS → JS 转换与格式化
120
+ - 组件:`CodeDemo` + `provideDemoContext` / `useDemoContext`(类型:`DemoModule` / `DemoSourceData` 等)
121
+ - 工具:`getDemoId` / `shortHash`
122
+
123
+ ## 本地开发
124
+
125
+ ```bash
126
+ pnpm install # 安装依赖
127
+ pnpm build # tsdown 构建 dist(插件部分;组件以 src 源码形态被站点引用)
128
+ pnpm dev # watch 模式
129
+ pnpm lint # eslint
130
+ pnpm typecheck # tsc --noEmit
131
+ ```
132
+
133
+ 下游站点开发期可通过 pnpm `link:` 引用本工程联调,发布后改为正常版本依赖。
@@ -0,0 +1,17 @@
1
+ import { JsFormatter } from "./tsToJs.js";
2
+ //#region src/demo/formatter.d.ts
3
+ interface OxfmtStyleOptions {
4
+ /** 语句末尾分号 */
5
+ semi?: boolean;
6
+ /** 使用单引号 */
7
+ singleQuote?: boolean;
8
+ [key: string]: unknown;
9
+ }
10
+ /**
11
+ * 基于 oxfmt 的内置 demo 源码 JS 格式化器。
12
+ * oxfmt 随本包安装,站点无需自行提供格式化器;
13
+ * 不可用时静默降级为不格式化。
14
+ */
15
+ declare function createOxfmtJsFormatter(style?: OxfmtStyleOptions): JsFormatter;
16
+ //#endregion
17
+ export { OxfmtStyleOptions, createOxfmtJsFormatter };
@@ -0,0 +1,32 @@
1
+ //#region src/demo/formatter.ts
2
+ let oxfmtPromise = null;
3
+ async function getOxfmtFormat() {
4
+ if (!oxfmtPromise) oxfmtPromise = (async () => {
5
+ try {
6
+ const { format } = await import("oxfmt");
7
+ return format;
8
+ } catch {
9
+ return null;
10
+ }
11
+ })();
12
+ return oxfmtPromise;
13
+ }
14
+ /**
15
+ * 基于 oxfmt 的内置 demo 源码 JS 格式化器。
16
+ * oxfmt 随本包安装,站点无需自行提供格式化器;
17
+ * 不可用时静默降级为不格式化。
18
+ */
19
+ function createOxfmtJsFormatter(style = {}) {
20
+ return async (code, lang) => {
21
+ try {
22
+ const format = await getOxfmtFormat();
23
+ if (!format) return code;
24
+ const result = await format(`virtual-demo-script.${lang === "tsx" ? "jsx" : "js"}`, code, style);
25
+ return result.errors.length > 0 ? code : result.code;
26
+ } catch {
27
+ return code;
28
+ }
29
+ };
30
+ }
31
+ //#endregion
32
+ export { createOxfmtJsFormatter };
@@ -0,0 +1,9 @@
1
+ //#region src/demo/get-demo-id.d.ts
2
+ /**
3
+ * 由 demo 文件路径生成稳定的锚点 id:
4
+ * 取路径中最后一个 `demo` 目录往前一级(组件目录)组成的路径,
5
+ * 去掉扩展名并把分隔符替换为 `-`。
6
+ */
7
+ declare function getDemoId(src: string): string;
8
+ //#endregion
9
+ export { getDemoId };
@@ -0,0 +1,19 @@
1
+ //#region src/demo/get-demo-id.ts
2
+ const BACKSLASH_RE = /\\/g;
3
+ const LEADING_SLASH_RE = /^\/+/;
4
+ const EXTENSION_RE = /\.[^.]+$/;
5
+ const PATH_SEP_RE = /[/\\.]/g;
6
+ /**
7
+ * 由 demo 文件路径生成稳定的锚点 id:
8
+ * 取路径中最后一个 `demo` 目录往前一级(组件目录)组成的路径,
9
+ * 去掉扩展名并把分隔符替换为 `-`。
10
+ */
11
+ function getDemoId(src) {
12
+ if (!src) return "";
13
+ const reversedSegments = [...src.replace(BACKSLASH_RE, "/").split("/").filter(Boolean)].reverse();
14
+ const demoIndex = reversedSegments.findIndex((segment) => segment.toLowerCase() === "demo");
15
+ if (demoIndex === -1) return src.replace(LEADING_SLASH_RE, "").replace(EXTENSION_RE, "").replace(PATH_SEP_RE, "-");
16
+ return reversedSegments.slice(0, demoIndex + 2).reverse().join("/").replace(EXTENSION_RE, "").replace(PATH_SEP_RE, "-");
17
+ }
18
+ //#endregion
19
+ export { getDemoId };
@@ -0,0 +1,26 @@
1
+ import { JsFormatter } from "./tsToJs.js";
2
+ import { PluginOption } from "vite";
3
+ //#region src/demo/index.d.ts
4
+ interface DemoPluginOptions {
5
+ /**
6
+ * demo 文件 glob(相对于项目 root)
7
+ * @default 收集 root 下所有 `demo` 目录内的 `.vue` 文件
8
+ */
9
+ include?: string[];
10
+ /**
11
+ * 任意 HMR 更新时提升 sourceVersion,促使已挂载的 demo 重新拉取源码
12
+ * @default true
13
+ */
14
+ sourceVersionOnHmr?: boolean;
15
+ /**
16
+ * TS -> JS 源码格式化器,默认使用内置的 oxfmt 格式化器
17
+ */
18
+ jsFormatter?: JsFormatter;
19
+ }
20
+ /**
21
+ * 将绝对路径转换为相对于项目根目录的路径
22
+ */
23
+ declare function toRelativePath(absolutePath: string, root: string): string;
24
+ declare function demoPlugin(options?: DemoPluginOptions): PluginOption;
25
+ //#endregion
26
+ export { DemoPluginOptions, demoPlugin, toRelativePath };
@@ -0,0 +1,359 @@
1
+ import { createOxfmtJsFormatter } from "./formatter.js";
2
+ import { createMarkdown, loadBaseMd, loadShiki } from "../markdown.js";
3
+ import { tsToJs } from "./tsToJs.js";
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+ import pm from "picomatch";
7
+ import { normalizePath } from "vite";
8
+ import { parse } from "vue/compiler-sfc";
9
+ //#region src/demo/index.ts
10
+ const EXT_LANG_MAP = {
11
+ ".json": "json",
12
+ ".ts": "ts",
13
+ ".tsx": "tsx",
14
+ ".js": "js",
15
+ ".jsx": "jsx",
16
+ ".mjs": "js",
17
+ ".cjs": "js",
18
+ ".vue": "vue",
19
+ ".css": "css",
20
+ ".less": "less",
21
+ ".scss": "scss",
22
+ ".md": "md",
23
+ ".html": "html"
24
+ };
25
+ function extLang(ext) {
26
+ return EXT_LANG_MAP[ext.toLowerCase()] ?? "text";
27
+ }
28
+ /**
29
+ * 收集 demo 内相对导入的伴生文件(用于多文件代码 tab 展示)。
30
+ * 跳过无扩展名的导入(如目录 index),避免内联无关模块。
31
+ */
32
+ async function collectExtraFiles(filePath, sourceCode) {
33
+ const dir = path.dirname(filePath);
34
+ const seen = /* @__PURE__ */ new Set();
35
+ const files = [];
36
+ const importRegex = /(?:from|import)\s*(?:\(\s*)?["'](\.{1,2}\/[^"']+)["']/g;
37
+ for (let match = importRegex.exec(sourceCode); match !== null; match = importRegex.exec(sourceCode)) {
38
+ const rel = match[1];
39
+ const ext = path.extname(rel);
40
+ if (seen.has(rel) || !ext) continue;
41
+ seen.add(rel);
42
+ const resolved = path.resolve(dir, rel);
43
+ try {
44
+ const content = await fs.readFile(resolved, "utf-8");
45
+ const lang = extLang(ext);
46
+ files.push({
47
+ name: rel,
48
+ lang,
49
+ code: content
50
+ });
51
+ } catch {}
52
+ }
53
+ return files;
54
+ }
55
+ /**
56
+ * 将绝对路径转换为相对于项目根目录的路径
57
+ */
58
+ function toRelativePath(absolutePath, root) {
59
+ const normalizedPath = normalizePath(absolutePath);
60
+ const normalizedRoot = normalizePath(root);
61
+ return normalizedPath.startsWith(normalizedRoot) ? normalizedPath.slice(normalizedRoot.length) : normalizedPath;
62
+ }
63
+ function isDemoFile(filePath, root, patterns) {
64
+ const relativePath = toRelativePath(filePath, root);
65
+ return patterns.some((pattern) => pm.isMatch(relativePath, pattern));
66
+ }
67
+ function toDemoKey(filePath, root) {
68
+ const relativePath = toRelativePath(filePath, root);
69
+ return relativePath.startsWith("/") ? relativePath : `/${relativePath}`;
70
+ }
71
+ /**
72
+ * 完整解析 demo 文件(用于 build 缓存和 dev source endpoint)
73
+ */
74
+ async function parseDemoFile(filePath, md, options) {
75
+ const code = await fs.readFile(filePath, "utf-8");
76
+ const locales = await parseDemoLocales(code, filePath, md);
77
+ const sourceCode = code.replace(/<docs[^>]*>[\s\S]*?<\/docs>/g, "").trim();
78
+ return {
79
+ locales,
80
+ sourceCode,
81
+ jsSourceCode: await tsToJs(sourceCode, options.jsFormatter),
82
+ extraFiles: await collectExtraFiles(filePath, sourceCode)
83
+ };
84
+ }
85
+ /**
86
+ * 仅解析 locales(用于 HMR 和 dev module code)
87
+ */
88
+ async function parseDemoLocales(code, filePath, md) {
89
+ const { descriptor } = parse(code, {
90
+ filename: filePath,
91
+ sourceMap: false
92
+ });
93
+ const locales = {};
94
+ const docsBlocks = descriptor.customBlocks.filter((block) => block.type === "docs");
95
+ await Promise.all(docsBlocks.map(async (block) => {
96
+ const lang = typeof block.attrs.lang === "string" ? block.attrs.lang : "zh-CN";
97
+ const env = {};
98
+ const html = await md.renderAsync(block.content.trim(), env);
99
+ const formatterTitle = env.formatters?.title;
100
+ locales[lang] = {
101
+ html,
102
+ title: formatterTitle || (typeof env.title === "string" ? env.title : "")
103
+ };
104
+ }));
105
+ return locales;
106
+ }
107
+ function serializeSourceData(parsed) {
108
+ const data = {
109
+ source: parsed.sourceCode,
110
+ jsSource: parsed.jsSourceCode,
111
+ extraFiles: parsed.extraFiles
112
+ };
113
+ return JSON.stringify(data);
114
+ }
115
+ function demoPlugin(options = {}) {
116
+ const md = createMarkdown()({
117
+ withPlugin: false,
118
+ config(md) {
119
+ loadBaseMd(md);
120
+ loadShiki(md);
121
+ }
122
+ });
123
+ const VIRTUAL_MODULE_ID = "virtual:demos";
124
+ const RESOLVED_VIRTUAL_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`;
125
+ const DEMO_SUFFIX = "demo=true";
126
+ const DEMO_GLOB = options.include ?? ["/src/pages/**/demo/*.vue"];
127
+ const DEMO_REGISTRY_ADD_EVENT = "demo-registry:add";
128
+ const DEMO_REGISTRY_REMOVE_EVENT = "demo-registry:remove";
129
+ const DEV_SOURCE_PATH = "/__demo_source";
130
+ const sourceVersionOnHmr = options.sourceVersionOnHmr ?? true;
131
+ const jsFormatter = options.jsFormatter ?? createOxfmtJsFormatter();
132
+ let isServe = false;
133
+ let root = process.cwd();
134
+ let base = "/";
135
+ const buildDemoParseCache = /* @__PURE__ */ new Map();
136
+ const devDemoParseTasks = /* @__PURE__ */ new Map();
137
+ async function getBuildParsedDemo(filePath) {
138
+ if (!buildDemoParseCache.has(filePath)) buildDemoParseCache.set(filePath, await parseDemoFile(filePath, md, { jsFormatter }));
139
+ return buildDemoParseCache.get(filePath);
140
+ }
141
+ function getDevParsedDemo(filePath) {
142
+ const currentTask = devDemoParseTasks.get(filePath);
143
+ if (currentTask) return currentTask;
144
+ const task = parseDemoFile(filePath, md, { jsFormatter }).finally(() => {
145
+ if (devDemoParseTasks.get(filePath) === task) devDemoParseTasks.delete(filePath);
146
+ });
147
+ devDemoParseTasks.set(filePath, task);
148
+ return task;
149
+ }
150
+ return {
151
+ name: "vite:demo",
152
+ enforce: "pre",
153
+ configResolved(config) {
154
+ isServe = config.command === "serve";
155
+ root = config.root;
156
+ base = config.base;
157
+ },
158
+ configureServer(server) {
159
+ const sourcePath = `${base === "/" ? "" : base.replace(/\/$/, "")}${DEV_SOURCE_PATH}`;
160
+ server.middlewares.use(async (request, response, next) => {
161
+ const url = new URL(request.url ?? "", "http://vite.local");
162
+ if (url.pathname !== sourcePath) return next();
163
+ const id = url.searchParams.get("id");
164
+ const filePath = id ? path.resolve(root, `.${id}`) : "";
165
+ const relativePath = filePath ? path.relative(root, filePath) : "..";
166
+ if (!id?.startsWith("/") || relativePath.startsWith("..") || path.isAbsolute(relativePath) || !isDemoFile(filePath, root, DEMO_GLOB)) {
167
+ response.statusCode = 400;
168
+ response.end("Invalid demo source path");
169
+ return;
170
+ }
171
+ try {
172
+ const parsed = await getDevParsedDemo(filePath);
173
+ response.statusCode = 200;
174
+ response.setHeader("Content-Type", "application/json; charset=utf-8");
175
+ response.setHeader("Cache-Control", "no-store");
176
+ response.end(serializeSourceData(parsed));
177
+ } catch (error) {
178
+ server.config.logger.error(`Failed to load demo source ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
179
+ response.statusCode = 500;
180
+ response.end("Failed to load demo source");
181
+ }
182
+ });
183
+ const handleDemoAdd = (filePath) => {
184
+ if (!isDemoFile(filePath, server.config.root, DEMO_GLOB)) return;
185
+ server.ws.send({
186
+ type: "custom",
187
+ event: DEMO_REGISTRY_ADD_EVENT,
188
+ data: {
189
+ id: toDemoKey(filePath, server.config.root),
190
+ timestamp: Date.now()
191
+ }
192
+ });
193
+ };
194
+ const handleDemoRemove = (filePath) => {
195
+ if (!isDemoFile(filePath, server.config.root, DEMO_GLOB)) return;
196
+ server.ws.send({
197
+ type: "custom",
198
+ event: DEMO_REGISTRY_REMOVE_EVENT,
199
+ data: { id: toDemoKey(filePath, server.config.root) }
200
+ });
201
+ };
202
+ server.watcher.on("add", handleDemoAdd);
203
+ server.watcher.on("unlink", handleDemoRemove);
204
+ },
205
+ async resolveId(id, importer) {
206
+ if (id === VIRTUAL_MODULE_ID) return RESOLVED_VIRTUAL_MODULE_ID;
207
+ if (id.includes(DEMO_SUFFIX)) {
208
+ const resolved = await this.resolve(id, importer, { skipSelf: true });
209
+ if (resolved) return `\0${resolved.id}`;
210
+ }
211
+ },
212
+ async load(id) {
213
+ const [, query] = id.split("?");
214
+ const params = new URLSearchParams(query);
215
+ if (params.get("vue") !== null && params.get("type") === "docs") return "export default {}";
216
+ if (id === RESOLVED_VIRTUAL_MODULE_ID) return `
217
+ import { shallowReactive } from 'vue'
218
+
219
+ const demoLoaders = import.meta.glob(${JSON.stringify(DEMO_GLOB)}, {
220
+ query: { demo: 'true' },
221
+ })
222
+
223
+ const demos = shallowReactive({})
224
+
225
+ async function registerDemo(id, timestamp = Date.now()) {
226
+ const mod = await import(/* @vite-ignore */ \`\${id}?demo=true&t=\${timestamp}\`)
227
+ demos[id] = mod.default ?? mod
228
+ }
229
+
230
+ export async function loadDemo(id) {
231
+ if (demos[id])
232
+ return demos[id]
233
+
234
+ const loader = demoLoaders[id]
235
+ if (!loader)
236
+ return null
237
+
238
+ const mod = await loader()
239
+ demos[id] = mod.default ?? mod
240
+ return demos[id]
241
+ }
242
+
243
+ function removeDemo(id) {
244
+ delete demos[id]
245
+ }
246
+
247
+ if (import.meta.hot) {
248
+ import.meta.hot.accept()
249
+ import.meta.hot.on(${JSON.stringify(DEMO_REGISTRY_ADD_EVENT)}, async (data) => {
250
+ if (!data?.id)
251
+ return
252
+ await registerDemo(data.id, data.timestamp)
253
+ })
254
+ import.meta.hot.on(${JSON.stringify(DEMO_REGISTRY_REMOVE_EVENT)}, (data) => {
255
+ if (!data?.id)
256
+ return
257
+ removeDemo(data.id)
258
+ })
259
+ }
260
+
261
+ export default demos
262
+ `;
263
+ if (id.startsWith("\0") && id.includes(DEMO_SUFFIX)) {
264
+ const [filePath] = id.slice(1).split("?");
265
+ if (!filePath) return;
266
+ const normalizedFile = normalizePath(filePath);
267
+ this.addWatchFile(filePath);
268
+ const parsed = isServe ? void 0 : await getBuildParsedDemo(filePath);
269
+ const locales = parsed ? parsed.locales : await parseDemoLocales(await fs.readFile(filePath, "utf-8"), filePath, md);
270
+ for (const file of parsed?.extraFiles ?? []) this.addWatchFile(path.resolve(path.dirname(filePath), file.name));
271
+ const sourceUrl = isServe ? void 0 : this.getFileName(this.emitFile({
272
+ type: "asset",
273
+ name: `demo-source-${path.basename(filePath, ".vue")}.json`,
274
+ source: serializeSourceData(parsed)
275
+ }));
276
+ return {
277
+ code: isServe ? `
278
+ import { ref } from 'vue'
279
+
280
+ const localesRef = ref(${JSON.stringify(locales)})
281
+ const sourceVersionRef = ref(0)
282
+
283
+ const demoData = {
284
+ component: () => import(${JSON.stringify(filePath)}),
285
+ get locales() { return localesRef.value },
286
+ get sourceVersion() { return sourceVersionRef.value },
287
+ async loadSource(signal) {
288
+ const url = new URL(import.meta.env.BASE_URL + ${JSON.stringify(DEV_SOURCE_PATH.slice(1))}, window.location.origin)
289
+ url.searchParams.set('id', ${JSON.stringify(toDemoKey(filePath, root))})
290
+ url.searchParams.set('t', String(sourceVersionRef.value))
291
+ const res = await fetch(url.href, { cache: 'no-store', signal })
292
+ if (!res.ok)
293
+ throw new Error(\`Failed to load demo source: \${res.status} \${res.statusText}\`)
294
+ return res.json()
295
+ }
296
+ }
297
+
298
+ if (import.meta.hot) {
299
+ import.meta.hot.accept()${sourceVersionOnHmr ? `
300
+ import.meta.hot.on('vite:beforeUpdate', () => {
301
+ sourceVersionRef.value = Date.now()
302
+ })` : ""}
303
+ import.meta.hot.on(${JSON.stringify(`demo-update:${normalizedFile}`)}, (data) => {
304
+ if ('locales' in data) localesRef.value = data.locales
305
+ if ('timestamp' in data) sourceVersionRef.value = data.timestamp
306
+ })
307
+ }
308
+
309
+ export default demoData
310
+ ` : `
311
+ import { ref } from 'vue'
312
+
313
+ const localesRef = ref(${JSON.stringify(locales)})
314
+
315
+ const demoData = {
316
+ component: () => import(${JSON.stringify(filePath)}),
317
+ get locales() { return localesRef.value },
318
+ sourceVersion: 0,
319
+ async loadSource(signal) {
320
+ const url = new URL(import.meta.env.BASE_URL + ${JSON.stringify(sourceUrl)}, import.meta.url)
321
+ const res = await fetch(url.href, { signal })
322
+ if (!res.ok)
323
+ throw new Error(\`Failed to load demo source: \${res.status} \${res.statusText}\`)
324
+ return res.json()
325
+ }
326
+ }
327
+
328
+ if (import.meta.hot) {
329
+ import.meta.hot.accept()
330
+ import.meta.hot.on(${JSON.stringify(`demo-update:${normalizedFile}`)}, (data) => {
331
+ if ('locales' in data) localesRef.value = data.locales
332
+ })
333
+ }
334
+
335
+ export default demoData
336
+ `,
337
+ map: null
338
+ };
339
+ }
340
+ },
341
+ async handleHotUpdate(ctx) {
342
+ if (!isDemoFile(ctx.file, ctx.server.config.root, DEMO_GLOB)) return;
343
+ const normalizedFile = normalizePath(ctx.file);
344
+ buildDemoParseCache.delete(ctx.file);
345
+ const locales = await parseDemoLocales(await fs.readFile(ctx.file, "utf-8"), ctx.file, md);
346
+ ctx.server.ws.send({
347
+ type: "custom",
348
+ event: `demo-update:${normalizedFile}`,
349
+ data: {
350
+ locales,
351
+ timestamp: Date.now()
352
+ }
353
+ });
354
+ return ctx.modules;
355
+ }
356
+ };
357
+ }
358
+ //#endregion
359
+ export { demoPlugin, toRelativePath };
@@ -0,0 +1,9 @@
1
+ //#region src/demo/tsToJs.d.ts
2
+ /**
3
+ * TS -> JS 源码的格式化器。
4
+ * 站点各自注入(如 eslint / oxfmt),失败时返回原始代码即可。
5
+ */
6
+ type JsFormatter = (code: string, lang: string) => Promise<string> | string;
7
+ declare function tsToJs(sourceCode: string, format?: JsFormatter): Promise<string>;
8
+ //#endregion
9
+ export { JsFormatter, tsToJs };
@@ -0,0 +1,60 @@
1
+ import { transformWithOxc } from "vite";
2
+ //#region src/demo/tsToJs.ts
3
+ const SCRIPT_BLOCK_REGEX = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
4
+ const SCRIPT_LANG_REGEX = /\blang\s*=\s*(['"]?)([\w-]+)\1/i;
5
+ const TS_LANGS = /* @__PURE__ */ new Set([
6
+ "ts",
7
+ "tsx",
8
+ "mts",
9
+ "cts"
10
+ ]);
11
+ const EXPORT_MARKER_REGEX = /\n?export\s*\{\s*\};?\s*$/u;
12
+ async function transpileScript(code, lang) {
13
+ const oxcLang = lang === "tsx" ? "tsx" : "ts";
14
+ return (await transformWithOxc(code, `virtual-demo-script.${oxcLang}`, {
15
+ lang: oxcLang,
16
+ sourceType: "module",
17
+ target: "es2020",
18
+ jsx: oxcLang === "tsx" ? "preserve" : void 0,
19
+ typescript: { onlyRemoveTypeImports: true },
20
+ sourcemap: false
21
+ })).code.replace(EXPORT_MARKER_REGEX, "");
22
+ }
23
+ async function tsToJs(sourceCode, format) {
24
+ let nextSourceCode = "";
25
+ let lastIndex = 0;
26
+ SCRIPT_BLOCK_REGEX.lastIndex = 0;
27
+ for (const match of sourceCode.matchAll(SCRIPT_BLOCK_REGEX)) {
28
+ const [fullMatch, attrs = "", code = ""] = match;
29
+ const startIndex = match.index ?? 0;
30
+ nextSourceCode += sourceCode.slice(lastIndex, startIndex);
31
+ const langMatch = attrs.match(SCRIPT_LANG_REGEX);
32
+ if (!langMatch) {
33
+ nextSourceCode += fullMatch;
34
+ lastIndex = startIndex + fullMatch.length;
35
+ continue;
36
+ }
37
+ const [, quote, lang = ""] = langMatch;
38
+ const normalizedLang = lang.toLowerCase();
39
+ if (!TS_LANGS.has(normalizedLang)) {
40
+ nextSourceCode += fullMatch;
41
+ lastIndex = startIndex + fullMatch.length;
42
+ continue;
43
+ }
44
+ const nextLang = normalizedLang === "tsx" ? "jsx" : "js";
45
+ const wrappedQuote = quote || "\"";
46
+ const nextAttrs = attrs.replace(SCRIPT_LANG_REGEX, `lang=${wrappedQuote}${nextLang}${wrappedQuote}`);
47
+ try {
48
+ const transpiledCode = await transpileScript(code, normalizedLang);
49
+ const normalizedCode = format ? await format(transpiledCode, normalizedLang) : transpiledCode;
50
+ nextSourceCode += `<script${nextAttrs}>\n${normalizedCode.trim()}\n<\/script>`;
51
+ } catch {
52
+ nextSourceCode += fullMatch;
53
+ }
54
+ lastIndex = startIndex + fullMatch.length;
55
+ }
56
+ nextSourceCode += sourceCode.slice(lastIndex);
57
+ return nextSourceCode;
58
+ }
59
+ //#endregion
60
+ export { tsToJs };