@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
@@ -0,0 +1,38 @@
1
+ /**
2
+ * `virtual:demos` 运行时模块的类型声明。
3
+ * 该虚拟模块由本包的 demoPlugin 在站点 vite 中生成:
4
+ * - 默认导出: demo 注册表(懒加载模式下的 shallowReactive 记录)
5
+ * - loadDemo: 按需加载单个 demo
6
+ *
7
+ * 站点 TS 程序通过本文件获得类型;站点自身的 types 目录不再需要重复声明。
8
+ */
9
+ declare module 'virtual:demos' {
10
+ interface DemoLocale {
11
+ html?: string
12
+ title?: string
13
+ }
14
+
15
+ export interface DemoExtraFile {
16
+ name: string
17
+ lang: string
18
+ code: string
19
+ }
20
+
21
+ export interface DemoSourceData {
22
+ source: string
23
+ jsSource: string
24
+ extraFiles: DemoExtraFile[]
25
+ }
26
+
27
+ export interface DemoModule {
28
+ component?: () => Promise<unknown>
29
+ locales?: Record<string, DemoLocale>
30
+ sourceVersion: number
31
+ loadSource: (signal?: AbortSignal) => Promise<DemoSourceData>
32
+ }
33
+
34
+ export function loadDemo(id: string): Promise<DemoModule | null>
35
+
36
+ const demos: Record<string, DemoModule>
37
+ export default demos
38
+ }
@@ -0,0 +1,50 @@
1
+ import type { JsFormatter } from './tsToJs'
2
+
3
+ type OxfmtFormat = (typeof import('oxfmt'))['format']
4
+
5
+ export interface OxfmtStyleOptions {
6
+ /** 语句末尾分号 */
7
+ semi?: boolean
8
+ /** 使用单引号 */
9
+ singleQuote?: boolean
10
+ [key: string]: unknown
11
+ }
12
+
13
+ let oxfmtPromise: Promise<OxfmtFormat | null> | null = null
14
+
15
+ async function getOxfmtFormat() {
16
+ if (!oxfmtPromise) {
17
+ oxfmtPromise = (async () => {
18
+ try {
19
+ const { format } = await import('oxfmt')
20
+ return format
21
+ }
22
+ catch {
23
+ return null
24
+ }
25
+ })()
26
+ }
27
+ return oxfmtPromise
28
+ }
29
+
30
+ /**
31
+ * 基于 oxfmt 的内置 demo 源码 JS 格式化器。
32
+ * oxfmt 随本包安装,站点无需自行提供格式化器;
33
+ * 不可用时静默降级为不格式化。
34
+ */
35
+ export function createOxfmtJsFormatter(style: OxfmtStyleOptions = {}): JsFormatter {
36
+ return async (code, lang) => {
37
+ try {
38
+ const format = await getOxfmtFormat()
39
+ if (!format)
40
+ return code
41
+
42
+ const filePath = `virtual-demo-script.${lang === 'tsx' ? 'jsx' : 'js'}`
43
+ const result = await format(filePath, code, style)
44
+ return result.errors.length > 0 ? code : result.code
45
+ }
46
+ catch {
47
+ return code
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,33 @@
1
+ const BACKSLASH_RE = /\\/g
2
+ const LEADING_SLASH_RE = /^\/+/
3
+ const EXTENSION_RE = /\.[^.]+$/
4
+ const PATH_SEP_RE = /[/\\.]/g
5
+
6
+ /**
7
+ * 由 demo 文件路径生成稳定的锚点 id:
8
+ * 取路径中最后一个 `demo` 目录往前一级(组件目录)组成的路径,
9
+ * 去掉扩展名并把分隔符替换为 `-`。
10
+ */
11
+ export function getDemoId(src: string) {
12
+ if (!src)
13
+ return ''
14
+
15
+ const segments = src.replace(BACKSLASH_RE, '/').split('/').filter(Boolean)
16
+ const reversedSegments = [...segments].reverse()
17
+ const demoIndex = reversedSegments.findIndex(
18
+ segment => segment.toLowerCase() === 'demo',
19
+ )
20
+
21
+ if (demoIndex === -1) {
22
+ return src
23
+ .replace(LEADING_SLASH_RE, '')
24
+ .replace(EXTENSION_RE, '')
25
+ .replace(PATH_SEP_RE, '-')
26
+ }
27
+
28
+ const componentDemoPath = reversedSegments
29
+ .slice(0, demoIndex + 2)
30
+ .reverse()
31
+ .join('/')
32
+ return componentDemoPath.replace(EXTENSION_RE, '').replace(PATH_SEP_RE, '-')
33
+ }
@@ -0,0 +1,498 @@
1
+ import type { PluginOption } from 'vite'
2
+ import type { JsFormatter } from './tsToJs'
3
+ import type { DemoExtraFile, DemoSourceData } from './types'
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
+ import { createMarkdown, loadBaseMd, loadShiki } from '../markdown'
10
+ import { createOxfmtJsFormatter } from './formatter'
11
+ import { tsToJs } from './tsToJs'
12
+
13
+ interface ParsedDemoFile {
14
+ locales: Record<string, { html: string, title: string }>
15
+ sourceCode: string
16
+ jsSourceCode: string
17
+ extraFiles: DemoExtraFile[]
18
+ }
19
+
20
+ export interface DemoPluginOptions {
21
+ /**
22
+ * demo 文件 glob(相对于项目 root)
23
+ * @default 收集 root 下所有 `demo` 目录内的 `.vue` 文件
24
+ */
25
+ include?: string[]
26
+ /**
27
+ * 任意 HMR 更新时提升 sourceVersion,促使已挂载的 demo 重新拉取源码
28
+ * @default true
29
+ */
30
+ sourceVersionOnHmr?: boolean
31
+ /**
32
+ * TS -> JS 源码格式化器,默认使用内置的 oxfmt 格式化器
33
+ */
34
+ jsFormatter?: JsFormatter
35
+ }
36
+
37
+ const EXT_LANG_MAP: Record<string, string> = {
38
+ '.json': 'json',
39
+ '.ts': 'ts',
40
+ '.tsx': 'tsx',
41
+ '.js': 'js',
42
+ '.jsx': 'jsx',
43
+ '.mjs': 'js',
44
+ '.cjs': 'js',
45
+ '.vue': 'vue',
46
+ '.css': 'css',
47
+ '.less': 'less',
48
+ '.scss': 'scss',
49
+ '.md': 'md',
50
+ '.html': 'html',
51
+ }
52
+
53
+ function extLang(ext: string) {
54
+ return EXT_LANG_MAP[ext.toLowerCase()] ?? 'text'
55
+ }
56
+
57
+ /**
58
+ * 收集 demo 内相对导入的伴生文件(用于多文件代码 tab 展示)。
59
+ * 跳过无扩展名的导入(如目录 index),避免内联无关模块。
60
+ */
61
+ async function collectExtraFiles(
62
+ filePath: string,
63
+ sourceCode: string,
64
+ ): Promise<DemoExtraFile[]> {
65
+ const dir = path.dirname(filePath)
66
+ const seen = new Set<string>()
67
+ const files: DemoExtraFile[] = []
68
+
69
+ // Match `from './xxx'` or side-effect `import './xxx'`.
70
+ const importRegex = /(?:from|import)\s*(?:\(\s*)?["'](\.{1,2}\/[^"']+)["']/g
71
+
72
+ // `for` 的 update 表达式在 continue 时也会执行,避免重复/无扩展名导入导致死循环
73
+ for (
74
+ let match = importRegex.exec(sourceCode);
75
+ match !== null;
76
+ match = importRegex.exec(sourceCode)
77
+ ) {
78
+ const rel = match[1]!
79
+ const ext = path.extname(rel)
80
+ if (seen.has(rel) || !ext)
81
+ continue
82
+ seen.add(rel)
83
+
84
+ const resolved = path.resolve(dir, rel)
85
+ try {
86
+ const content = await fs.readFile(resolved, 'utf-8')
87
+ const lang = extLang(ext)
88
+ files.push({ name: rel, lang, code: content })
89
+ }
90
+ catch {
91
+ // ignore non-existent / non-readable files
92
+ }
93
+ }
94
+ return files
95
+ }
96
+
97
+ /**
98
+ * 将绝对路径转换为相对于项目根目录的路径
99
+ */
100
+ export function toRelativePath(absolutePath: string, root: string): string {
101
+ const normalizedPath = normalizePath(absolutePath)
102
+ const normalizedRoot = normalizePath(root)
103
+ return normalizedPath.startsWith(normalizedRoot)
104
+ ? normalizedPath.slice(normalizedRoot.length)
105
+ : normalizedPath
106
+ }
107
+
108
+ function isDemoFile(filePath: string, root: string, patterns: string[]) {
109
+ const relativePath = toRelativePath(filePath, root)
110
+ return patterns.some(pattern => pm.isMatch(relativePath, pattern))
111
+ }
112
+
113
+ function toDemoKey(filePath: string, root: string) {
114
+ const relativePath = toRelativePath(filePath, root)
115
+ return relativePath.startsWith('/') ? relativePath : `/${relativePath}`
116
+ }
117
+
118
+ /**
119
+ * 完整解析 demo 文件(用于 build 缓存和 dev source endpoint)
120
+ */
121
+ async function parseDemoFile(
122
+ filePath: string,
123
+ md: ReturnType<ReturnType<typeof createMarkdown>>,
124
+ options: { jsFormatter?: JsFormatter },
125
+ ): Promise<ParsedDemoFile> {
126
+ const code = await fs.readFile(filePath, 'utf-8')
127
+ const locales = await parseDemoLocales(code, filePath, md)
128
+
129
+ const sourceCode = code.replace(/<docs[^>]*>[\s\S]*?<\/docs>/g, '').trim()
130
+ const jsSourceCode = await tsToJs(sourceCode, options.jsFormatter)
131
+ const extraFiles = await collectExtraFiles(filePath, sourceCode)
132
+
133
+ return {
134
+ locales,
135
+ sourceCode,
136
+ jsSourceCode,
137
+ extraFiles,
138
+ }
139
+ }
140
+
141
+ /**
142
+ * 仅解析 locales(用于 HMR 和 dev module code)
143
+ */
144
+ async function parseDemoLocales(
145
+ code: string,
146
+ filePath: string,
147
+ md: ReturnType<ReturnType<typeof createMarkdown>>,
148
+ ) {
149
+ const { descriptor } = parse(code, {
150
+ filename: filePath,
151
+ sourceMap: false,
152
+ })
153
+
154
+ const locales: Record<string, { html: string, title: string }> = {}
155
+ const docsBlocks = descriptor.customBlocks.filter(block => block.type === 'docs')
156
+ await Promise.all(docsBlocks.map(async (block) => {
157
+ const lang = typeof block.attrs.lang === 'string' ? block.attrs.lang : 'zh-CN'
158
+ const env: Record<string, unknown> = {}
159
+ const html = await md.renderAsync(block.content.trim(), env)
160
+ const formatterTitle = (env.formatters as { title?: string } | undefined)?.title
161
+ locales[lang] = {
162
+ html,
163
+ title: formatterTitle || (typeof env.title === 'string' ? env.title : ''),
164
+ }
165
+ }))
166
+ return locales
167
+ }
168
+
169
+ function serializeSourceData(parsed: ParsedDemoFile): string {
170
+ const data: DemoSourceData = {
171
+ source: parsed.sourceCode,
172
+ jsSource: parsed.jsSourceCode,
173
+ extraFiles: parsed.extraFiles,
174
+ }
175
+ return JSON.stringify(data)
176
+ }
177
+
178
+ export function demoPlugin(options: DemoPluginOptions = {}): PluginOption {
179
+ const md = createMarkdown()({
180
+ withPlugin: false,
181
+ config(md) {
182
+ loadBaseMd(md)
183
+ loadShiki(md)
184
+ },
185
+ })
186
+ const VIRTUAL_MODULE_ID = 'virtual:demos'
187
+ const RESOLVED_VIRTUAL_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`
188
+ const DEMO_SUFFIX = 'demo=true'
189
+ const DEMO_GLOB = options.include ?? ['/src/pages/**/demo/*.vue']
190
+ const DEMO_REGISTRY_ADD_EVENT = 'demo-registry:add'
191
+ const DEMO_REGISTRY_REMOVE_EVENT = 'demo-registry:remove'
192
+ const DEV_SOURCE_PATH = '/__demo_source'
193
+ const sourceVersionOnHmr = options.sourceVersionOnHmr ?? true
194
+ const jsFormatter = options.jsFormatter ?? createOxfmtJsFormatter()
195
+ let isServe = false
196
+ let root = process.cwd()
197
+ let base = '/'
198
+
199
+ // Build 解析缓存(每个 demo 只解析一次);Dev 并发去重
200
+ const buildDemoParseCache = new Map<string, ParsedDemoFile>()
201
+ const devDemoParseTasks = new Map<string, Promise<ParsedDemoFile>>()
202
+
203
+ async function getBuildParsedDemo(filePath: string) {
204
+ if (!buildDemoParseCache.has(filePath)) {
205
+ buildDemoParseCache.set(filePath, await parseDemoFile(filePath, md, { jsFormatter }))
206
+ }
207
+ return buildDemoParseCache.get(filePath)!
208
+ }
209
+
210
+ function getDevParsedDemo(filePath: string) {
211
+ const currentTask = devDemoParseTasks.get(filePath)
212
+ if (currentTask)
213
+ return currentTask
214
+
215
+ const task = parseDemoFile(filePath, md, { jsFormatter }).finally(() => {
216
+ if (devDemoParseTasks.get(filePath) === task)
217
+ devDemoParseTasks.delete(filePath)
218
+ })
219
+ devDemoParseTasks.set(filePath, task)
220
+ return task
221
+ }
222
+
223
+ return {
224
+ name: 'vite:demo',
225
+ enforce: 'pre',
226
+ configResolved(config) {
227
+ isServe = config.command === 'serve'
228
+ root = config.root
229
+ base = config.base
230
+ },
231
+ configureServer(server) {
232
+ // Dev 模式下按需提供源码的 HTTP 端点
233
+ const sourcePath = `${base === '/' ? '' : base.replace(/\/$/, '')}${DEV_SOURCE_PATH}`
234
+ server.middlewares.use(async (request, response, next) => {
235
+ const url = new URL(request.url ?? '', 'http://vite.local')
236
+ if (url.pathname !== sourcePath)
237
+ return next()
238
+
239
+ const id = url.searchParams.get('id')
240
+ const filePath = id ? path.resolve(root, `.${id}`) : ''
241
+ const relativePath = filePath ? path.relative(root, filePath) : '..'
242
+ if (
243
+ !id?.startsWith('/')
244
+ || relativePath.startsWith('..')
245
+ || path.isAbsolute(relativePath)
246
+ || !isDemoFile(filePath, root, DEMO_GLOB)
247
+ ) {
248
+ response.statusCode = 400
249
+ response.end('Invalid demo source path')
250
+ return
251
+ }
252
+
253
+ try {
254
+ const parsed = await getDevParsedDemo(filePath)
255
+ response.statusCode = 200
256
+ response.setHeader('Content-Type', 'application/json; charset=utf-8')
257
+ response.setHeader('Cache-Control', 'no-store')
258
+ response.end(serializeSourceData(parsed))
259
+ }
260
+ catch (error) {
261
+ server.config.logger.error(
262
+ `Failed to load demo source ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
263
+ )
264
+ response.statusCode = 500
265
+ response.end('Failed to load demo source')
266
+ }
267
+ })
268
+
269
+ const handleDemoAdd = (filePath: string) => {
270
+ if (!isDemoFile(filePath, server.config.root, DEMO_GLOB))
271
+ return
272
+
273
+ server.ws.send({
274
+ type: 'custom',
275
+ event: DEMO_REGISTRY_ADD_EVENT,
276
+ data: {
277
+ id: toDemoKey(filePath, server.config.root),
278
+ timestamp: Date.now(),
279
+ },
280
+ })
281
+ }
282
+
283
+ const handleDemoRemove = (filePath: string) => {
284
+ if (!isDemoFile(filePath, server.config.root, DEMO_GLOB))
285
+ return
286
+
287
+ server.ws.send({
288
+ type: 'custom',
289
+ event: DEMO_REGISTRY_REMOVE_EVENT,
290
+ data: {
291
+ id: toDemoKey(filePath, server.config.root),
292
+ },
293
+ })
294
+ }
295
+
296
+ server.watcher.on('add', handleDemoAdd)
297
+ server.watcher.on('unlink', handleDemoRemove)
298
+ },
299
+ async resolveId(id, importer) {
300
+ if (id === VIRTUAL_MODULE_ID) {
301
+ return RESOLVED_VIRTUAL_MODULE_ID
302
+ }
303
+ if (id.includes(DEMO_SUFFIX)) {
304
+ const resolved = await this.resolve(id, importer, { skipSelf: true })
305
+ if (resolved) {
306
+ return `\0${resolved.id}`
307
+ }
308
+ }
309
+ },
310
+ async load(id) {
311
+ const [, query] = id.split('?')
312
+ const params = new URLSearchParams(query)
313
+ if (params.get('vue') !== null && params.get('type') === 'docs') {
314
+ return 'export default {}'
315
+ }
316
+ if (id === RESOLVED_VIRTUAL_MODULE_ID) {
317
+ // 懒加载注册表 + 开发时增删 demo 的 HMR 事件
318
+ return `
319
+ import { shallowReactive } from 'vue'
320
+
321
+ const demoLoaders = import.meta.glob(${JSON.stringify(DEMO_GLOB)}, {
322
+ query: { demo: 'true' },
323
+ })
324
+
325
+ const demos = shallowReactive({})
326
+
327
+ async function registerDemo(id, timestamp = Date.now()) {
328
+ const mod = await import(/* @vite-ignore */ \`\${id}?demo=true&t=\${timestamp}\`)
329
+ demos[id] = mod.default ?? mod
330
+ }
331
+
332
+ export async function loadDemo(id) {
333
+ if (demos[id])
334
+ return demos[id]
335
+
336
+ const loader = demoLoaders[id]
337
+ if (!loader)
338
+ return null
339
+
340
+ const mod = await loader()
341
+ demos[id] = mod.default ?? mod
342
+ return demos[id]
343
+ }
344
+
345
+ function removeDemo(id) {
346
+ delete demos[id]
347
+ }
348
+
349
+ if (import.meta.hot) {
350
+ import.meta.hot.accept()
351
+ import.meta.hot.on(${JSON.stringify(DEMO_REGISTRY_ADD_EVENT)}, async (data) => {
352
+ if (!data?.id)
353
+ return
354
+ await registerDemo(data.id, data.timestamp)
355
+ })
356
+ import.meta.hot.on(${JSON.stringify(DEMO_REGISTRY_REMOVE_EVENT)}, (data) => {
357
+ if (!data?.id)
358
+ return
359
+ removeDemo(data.id)
360
+ })
361
+ }
362
+
363
+ export default demos
364
+ `
365
+ }
366
+ if (id.startsWith('\0') && id.includes(DEMO_SUFFIX)) {
367
+ const virtualId = id.slice(1)
368
+ const [filePath] = virtualId.split('?')
369
+ if (!filePath)
370
+ return
371
+ const normalizedFile = normalizePath(filePath)
372
+
373
+ // 建立文件依赖关系
374
+ this.addWatchFile(filePath)
375
+
376
+ // Dev: 只解析 locales;Build: 完整解析
377
+ const parsed = isServe ? undefined : await getBuildParsedDemo(filePath)
378
+ const locales = parsed
379
+ ? parsed.locales
380
+ : await parseDemoLocales(
381
+ await fs.readFile(filePath, 'utf-8'),
382
+ filePath,
383
+ md,
384
+ )
385
+
386
+ // 监听伴生文件,保证多文件 demo 的 HMR 重新解析
387
+ for (const file of parsed?.extraFiles ?? []) {
388
+ this.addWatchFile(path.resolve(path.dirname(filePath), file.name))
389
+ }
390
+
391
+ // Build: 生成 JSON asset
392
+ const sourceUrl = isServe
393
+ ? undefined
394
+ : this.getFileName(
395
+ this.emitFile({
396
+ type: 'asset',
397
+ name: `demo-source-${path.basename(filePath, '.vue')}.json`,
398
+ source: serializeSourceData(parsed!),
399
+ }),
400
+ )
401
+
402
+ return {
403
+ code: isServe
404
+ ? `
405
+ import { ref } from 'vue'
406
+
407
+ const localesRef = ref(${JSON.stringify(locales)})
408
+ const sourceVersionRef = ref(0)
409
+
410
+ const demoData = {
411
+ component: () => import(${JSON.stringify(filePath)}),
412
+ get locales() { return localesRef.value },
413
+ get sourceVersion() { return sourceVersionRef.value },
414
+ async loadSource(signal) {
415
+ const url = new URL(import.meta.env.BASE_URL + ${JSON.stringify(DEV_SOURCE_PATH.slice(1))}, window.location.origin)
416
+ url.searchParams.set('id', ${JSON.stringify(toDemoKey(filePath, root))})
417
+ url.searchParams.set('t', String(sourceVersionRef.value))
418
+ const res = await fetch(url.href, { cache: 'no-store', signal })
419
+ if (!res.ok)
420
+ throw new Error(\`Failed to load demo source: \${res.status} \${res.statusText}\`)
421
+ return res.json()
422
+ }
423
+ }
424
+
425
+ if (import.meta.hot) {
426
+ import.meta.hot.accept()${sourceVersionOnHmr
427
+ ? `
428
+ import.meta.hot.on('vite:beforeUpdate', () => {
429
+ sourceVersionRef.value = Date.now()
430
+ })`
431
+ : ''}
432
+ import.meta.hot.on(${JSON.stringify(`demo-update:${normalizedFile}`)}, (data) => {
433
+ if ('locales' in data) localesRef.value = data.locales
434
+ if ('timestamp' in data) sourceVersionRef.value = data.timestamp
435
+ })
436
+ }
437
+
438
+ export default demoData
439
+ `
440
+ : `
441
+ import { ref } from 'vue'
442
+
443
+ const localesRef = ref(${JSON.stringify(locales)})
444
+
445
+ const demoData = {
446
+ component: () => import(${JSON.stringify(filePath)}),
447
+ get locales() { return localesRef.value },
448
+ sourceVersion: 0,
449
+ async loadSource(signal) {
450
+ const url = new URL(import.meta.env.BASE_URL + ${JSON.stringify(sourceUrl)}, import.meta.url)
451
+ const res = await fetch(url.href, { signal })
452
+ if (!res.ok)
453
+ throw new Error(\`Failed to load demo source: \${res.status} \${res.statusText}\`)
454
+ return res.json()
455
+ }
456
+ }
457
+
458
+ if (import.meta.hot) {
459
+ import.meta.hot.accept()
460
+ import.meta.hot.on(${JSON.stringify(`demo-update:${normalizedFile}`)}, (data) => {
461
+ if ('locales' in data) localesRef.value = data.locales
462
+ })
463
+ }
464
+
465
+ export default demoData
466
+ `,
467
+ map: null,
468
+ }
469
+ }
470
+ },
471
+ async handleHotUpdate(ctx) {
472
+ if (!isDemoFile(ctx.file, ctx.server.config.root, DEMO_GLOB))
473
+ return
474
+
475
+ const normalizedFile = normalizePath(ctx.file)
476
+
477
+ // 清除 build 缓存并重新解析 locales
478
+ buildDemoParseCache.delete(ctx.file)
479
+ const locales = await parseDemoLocales(
480
+ await fs.readFile(ctx.file, 'utf-8'),
481
+ ctx.file,
482
+ md,
483
+ )
484
+
485
+ ctx.server.ws.send({
486
+ type: 'custom',
487
+ event: `demo-update:${normalizedFile}`,
488
+ data: {
489
+ locales,
490
+ timestamp: Date.now(),
491
+ },
492
+ })
493
+
494
+ // 只返回原始 Vue 文件模块,让 Vue 的 HMR 处理组件更新
495
+ return ctx.modules
496
+ },
497
+ }
498
+ }
@@ -0,0 +1,79 @@
1
+ import { transformWithOxc } from 'vite'
2
+
3
+ /**
4
+ * TS -> JS 源码的格式化器。
5
+ * 站点各自注入(如 eslint / oxfmt),失败时返回原始代码即可。
6
+ */
7
+ export type JsFormatter = (code: string, lang: string) => Promise<string> | string
8
+
9
+ const SCRIPT_BLOCK_REGEX = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi
10
+ const SCRIPT_LANG_REGEX = /\blang\s*=\s*(['"]?)([\w-]+)\1/i
11
+ const TS_LANGS = new Set(['ts', 'tsx', 'mts', 'cts'])
12
+ const EXPORT_MARKER_REGEX = /\n?export\s*\{\s*\};?\s*$/u
13
+
14
+ async function transpileScript(code: string, lang: string) {
15
+ const oxcLang = lang === 'tsx' ? 'tsx' : 'ts'
16
+ const result = await transformWithOxc(code, `virtual-demo-script.${oxcLang}`, {
17
+ lang: oxcLang,
18
+ sourceType: 'module',
19
+ target: 'es2020',
20
+ jsx: oxcLang === 'tsx' ? 'preserve' : undefined,
21
+ typescript: {
22
+ onlyRemoveTypeImports: true,
23
+ },
24
+ sourcemap: false,
25
+ })
26
+
27
+ // remove `export {}`
28
+ return result.code.replace(EXPORT_MARKER_REGEX, '')
29
+ }
30
+
31
+ export async function tsToJs(sourceCode: string, format?: JsFormatter) {
32
+ let nextSourceCode = ''
33
+ let lastIndex = 0
34
+ SCRIPT_BLOCK_REGEX.lastIndex = 0
35
+
36
+ for (const match of sourceCode.matchAll(SCRIPT_BLOCK_REGEX)) {
37
+ const [fullMatch, attrs = '', code = ''] = match
38
+ const startIndex = match.index ?? 0
39
+ nextSourceCode += sourceCode.slice(lastIndex, startIndex)
40
+
41
+ const langMatch = attrs.match(SCRIPT_LANG_REGEX)
42
+ if (!langMatch) {
43
+ nextSourceCode += fullMatch
44
+ lastIndex = startIndex + fullMatch.length
45
+ continue
46
+ }
47
+
48
+ const [, quote, lang = ''] = langMatch
49
+ const normalizedLang = lang.toLowerCase()
50
+ if (!TS_LANGS.has(normalizedLang)) {
51
+ nextSourceCode += fullMatch
52
+ lastIndex = startIndex + fullMatch.length
53
+ continue
54
+ }
55
+
56
+ const nextLang = normalizedLang === 'tsx' ? 'jsx' : 'js'
57
+ const wrappedQuote = quote || '"'
58
+ const nextAttrs = attrs.replace(
59
+ SCRIPT_LANG_REGEX,
60
+ `lang=${wrappedQuote}${nextLang}${wrappedQuote}`,
61
+ )
62
+
63
+ try {
64
+ const transpiledCode = await transpileScript(code, normalizedLang)
65
+ const normalizedCode = format
66
+ ? await format(transpiledCode, normalizedLang)
67
+ : transpiledCode
68
+ nextSourceCode += `<script${nextAttrs}>\n${normalizedCode.trim()}\n</script>`
69
+ }
70
+ catch {
71
+ nextSourceCode += fullMatch
72
+ }
73
+
74
+ lastIndex = startIndex + fullMatch.length
75
+ }
76
+
77
+ nextSourceCode += sourceCode.slice(lastIndex)
78
+ return nextSourceCode
79
+ }