diff --git a/.gitignore b/.gitignore index 14d44d182c7326226da21a64a810170cf9f4032d..a2ceb01bc9666b6b9e605edbd126747978302b0d 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ dist-ssr *.njsproj *.sln *.sw? +# 脚本生成的文件 +*api.zh-CN.md diff --git a/package.json b/package.json index 3104b56d6e086388d80b8c1af70fd4df27a6cfba..12e944ae09c964c29ad4d5ab355a191ea8778e60 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "dependencies": { "normalize.css": "catalog:", "vue": "catalog:", - "vue-router": "catalog:" + "vue-router": "catalog:", + "@opensig/opendesign": "workspace:^" }, "devDependencies": { "@rushstack/eslint-patch": "catalog:", @@ -21,6 +22,7 @@ "@vue/eslint-config-typescript": "catalog:", "eslint": "catalog:", "eslint-plugin-vue": "catalog:", + "prettier": "catalog:", "typescript": "catalog:" } } diff --git a/packages/opendesign/src/button/__docs__/OButton-api.en-US.md b/packages/opendesign/src/button/__docs__/OButton-api.en-US.md new file mode 100644 index 0000000000000000000000000000000000000000..6dab018332c335a038c67ddc057f17883a327fdb --- /dev/null +++ b/packages/opendesign/src/button/__docs__/OButton-api.en-US.md @@ -0,0 +1,29 @@ +## API OButton + +### Props + +| Prop Name | Type | Description | +| --------- | ------------------------------------------------------------------------ | ---------------------------- | +| disabled | boolean \| undefined | Disabled state | +| loading | boolean \| undefined | Loading state | +| color | "success" \| "normal" \| "primary" \| "warning" \| "danger" \| undefined | Color type (ColorT) | +| variant | "text" \| "solid" \| "outline" \| undefined | Button variant (VariantT) | +| tag | string \| undefined | Custom element tag | +| size | "medium" \| "small" \| "large" \| undefined | Button size (ButtonSizeT) | +| icon | Component \| undefined | Prefix icon | +| href | string \| undefined | Link URL | +| round | string \| undefined | Border radius value (RoundT) | + +### Events + +| Event Name | Signature | Description | +| ---------- | --------------------------------------- | -------------------------------- | +| click | (event: "click", evt: MouseEvent): void | Triggered when button is clicked | + +### Slots + +| Slot Name | Props | +| --------- | ----- | +| icon | {} | +| default | {} | +| suffix | {} | diff --git a/packages/opendesign/src/button/__docs__/__demo__/BtnIconSize.vue b/packages/opendesign/src/button/__docs__/__demo__/BtnIconSize.vue new file mode 100644 index 0000000000000000000000000000000000000000..e6b2355d65eb2064c8875c0aa97eaa495eeaaed4 --- /dev/null +++ b/packages/opendesign/src/button/__docs__/__demo__/BtnIconSize.vue @@ -0,0 +1,71 @@ + +--- +title: + zh-CN: icon插槽及按钮大小 + en-US: Icon Slot and Button Size +--- + +# zh-CN + +可以通过`icon`插槽设置按钮图标,通过`size`属性设置按钮大小。 + +# en-US +The button icon can be set using the `icon` slot, and the button size can be adjusted via the `size` prop. + + + diff --git a/packages/opendesign/src/button/__docs__/__demo__/BtnLoading.vue b/packages/opendesign/src/button/__docs__/__demo__/BtnLoading.vue new file mode 100644 index 0000000000000000000000000000000000000000..d1c7d8924f783cef7c2470594bbf2032483e0cde --- /dev/null +++ b/packages/opendesign/src/button/__docs__/__demo__/BtnLoading.vue @@ -0,0 +1,40 @@ + +--- +title: + zh-CN: 加载中 + en-US: Loading +--- + +# zh-CN + +加载中的按钮,通过`loading`属性控制按钮的加载状态。当`loading`为`true`时,按钮会显示加载中的状态。同时`icon`插槽会被替换为加载中的图标。 + +# en-US +A button that is loading, controlled by the `loading` property. When `loading` is `true`, the button will display the loading state. The `icon` slot will be replaced with the loading icon. + + + diff --git a/packages/opendesign/src/button/__docs__/__demo__/BtnRound.vue b/packages/opendesign/src/button/__docs__/__demo__/BtnRound.vue new file mode 100644 index 0000000000000000000000000000000000000000..35f95b7c1e0af4e977cc1c3d6e06dd81c3a6304b --- /dev/null +++ b/packages/opendesign/src/button/__docs__/__demo__/BtnRound.vue @@ -0,0 +1,71 @@ + +--- +title: + zh-CN: 按钮圆角形状 + en-US: Button Round Shape +--- + +# zh-CN + +通过`round`属性设置按钮圆角形状,值为`pill`时指半圆角,值也可以是css属性`border-radius`可接受的任意值 + +# en-US + +Sets the button's rounded shape. A value of 'pill' specifies a half-rounded shape. Any valid CSS border-radius length value (e.g., '8px', '50%') can be used. + + + diff --git a/packages/opendesign/src/button/__docs__/index.en-US.md b/packages/opendesign/src/button/__docs__/index.en-US.md new file mode 100644 index 0000000000000000000000000000000000000000..542b2d7ca5ae4f3b067aa8e071b02f6bece84e1f --- /dev/null +++ b/packages/opendesign/src/button/__docs__/index.en-US.md @@ -0,0 +1,13 @@ +--- +sidebar: OButton +--- + +# button + +button + +## demo + + + + diff --git a/packages/opendesign/src/button/__docs__/index.zh-CN.md b/packages/opendesign/src/button/__docs__/index.zh-CN.md new file mode 100644 index 0000000000000000000000000000000000000000..0cbfe7d5f193df6b48e0b1b55b63b78f12587670 --- /dev/null +++ b/packages/opendesign/src/button/__docs__/index.zh-CN.md @@ -0,0 +1,13 @@ +--- +sidebar: OButton 按钮 +--- + +# 按钮 + +按钮 + +## 示例 + + + + diff --git a/packages/opendesign/src/icon-components/icons.json b/packages/opendesign/src/icon-components/icons.json index 5492ac530ec63e05256c2a2a3a6b2f93bced328a..07dcad2eea2fb2d8f272a1861c1f3b89f2587be2 100644 --- a/packages/opendesign/src/icon-components/icons.json +++ b/packages/opendesign/src/icon-components/icons.json @@ -263,4 +263,4 @@ "componentName": "OIconAscend", "path": "color/ascend.svg" } -] \ No newline at end of file +] diff --git a/packages/portal/.gitignore b/packages/portal/.gitignore index abcfe591f2bc9eb29306939345ca50aac1abc32f..9c59933ecfcb2b6e033044329c5f47ca81a2db57 100644 --- a/packages/portal/.gitignore +++ b/packages/portal/.gitignore @@ -25,4 +25,5 @@ dist-ssr icon-components es -lib \ No newline at end of file +lib +src/router/components.ts \ No newline at end of file diff --git a/packages/portal/helper/utils.ts b/packages/portal/helper/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..6f46ddab7ca8c4d7a928873b286ee703b857f9d4 --- /dev/null +++ b/packages/portal/helper/utils.ts @@ -0,0 +1,8 @@ +export function getLangByFileName(_fileName: string) { + const fileName = _fileName.slice(_fileName.lastIndexOf('/') + 1); + const [name, lang, ext] = fileName.split('.'); + if (!ext) { + return { name, lang: '', ext: lang }; + } + return { name, lang, ext }; +} diff --git a/packages/portal/package.json b/packages/portal/package.json index d234d06c1e5423f831bc5f76947505803e9cc5c5..2f4eeee6edea47d3dff4b4ad8328ae4529787762 100644 --- a/packages/portal/package.json +++ b/packages/portal/package.json @@ -10,14 +10,29 @@ "gen:icon": "open-scripts gen:icon --config __test_scripts/icons/icon.config.ts" }, "dependencies": { - "@opensig/opendesign": "workspace:^", + "@opensig/open-analytics": "catalog:", "@opensig/open-scripts": "workspace:^", - "@opensig/open-analytics": "catalog:" + "markdown-it": "catalog:", + "pinia": "catalog:", + "vue": "catalog:" }, "devDependencies": { + "@types/fs-extra": "catalog:", + "@types/markdown-it": "catalog:", + "@vue/compiler-sfc": "catalog:", + "fs-extra": "catalog:", + "glob": "catalog:", + "gray-matter": "catalog:", + "highlight.js": "catalog:", + "magic-string": "catalog:", + "markdown-it-async": "catalog:", "sass": "catalog:", "typescript": "catalog:", + "unplugin-vue-markdown": "catalog:", "vite": "catalog:", + "vite-plugin-inspect": "catalog:", + "vue-component-meta": "catalog:", + "vue-docgen-api": "catalog:", "vue-tsc": "catalog:" } } diff --git a/packages/portal/plugins/generateComponentRouter.ts b/packages/portal/plugins/generateComponentRouter.ts new file mode 100644 index 0000000000000000000000000000000000000000..75c52f03d7591fb279a7913a40c463db41e7f88a --- /dev/null +++ b/packages/portal/plugins/generateComponentRouter.ts @@ -0,0 +1,96 @@ +import { createFilter, type Plugin } from 'vite'; +import { glob } from 'glob'; +import { resolve } from 'path'; +import { fileURLToPath } from 'url'; +import fse from 'fs-extra'; +import matter from 'gray-matter'; +import * as prettier from 'prettier'; +import tsPlugin from 'prettier/plugins/typescript'; +import { getLangByFileName } from '../helper/utils'; + +const __fileName = fileURLToPath(import.meta.url); +const searchBase = resolve(__fileName, '../../../opendesign/src'); +const output = resolve(__fileName, '../../src/router/components.ts'); + +function debounce) => any>(fn: T, wait: number = 0, runFirst: boolean = true) { + let handler = null; + return (...args: Array) => { + if (runFirst) { + if (!handler) { + fn(...args); + } + } + clearTimeout(handler); + handler = setTimeout(() => { + if (!runFirst) { + fn(...args); + } + handler = 0; + }, wait); + }; +} + +const emit = debounce(() => { + glob('**/__docs__/index.*.md', { cwd: searchBase, posix: true }) + .then((files) => { + return files.map((file) => { + const fullPath = resolve(searchBase, file); + const content = fse.readFileSync(fullPath).toString(); + return { content, file, fullPath, name: file.match(/([^\/]+)\/__docs__\/?/)?.[1], lang: getLangByFileName(file).lang }; + }); + }) + .then((fileContents) => { + const headCommentRegex = /^---\s*([\s\S]*?)\s*---/; + return fileContents.map((info) => { + const match = info.content.match(headCommentRegex); + const matterData = match ? matter(match[0]) : { data: {} }; + return { + ...info, + meta: { + ...matterData.data, + lang: info.lang, + }, + }; + }); + }) + .then((res) => { + return `import { type RouteRecordRaw } from 'vue-router'; +export const routes: Array = [ +${res + .map( + (info) => ` { + path: '/${info.lang}/components/${info.name}', + name: 'component/${info.name}/${info.lang}', + component: () => import('@components/${info.file}'), + meta: ${JSON.stringify(info.meta)} + }`, + ) + .join(',')} +]; + `; + }) + .then((res) => { + return prettier.format(res, { parser: 'typescript', plugins: [tsPlugin], singleQuote: true, printWidth: 160 }); + }) + .then((res) => { + return fse.writeFile(output, res); + }); +}, 1000); + +export default function generateComponentRouter(): Plugin { + const filter = createFilter(/opendesign\/src\/.*?\/__docs__\/index\..*?\.md$/); + return { + name: 'generate-component-router', + configureServer(server) { + server.watcher.add(searchBase); + server.watcher.on('all', (event, path) => { + if (filter(path.replace(/\\/g, '/')) && ['add', 'unlink'].includes(event)) { + emit(); + } + }); + }, + buildStart() { + emit(); + }, + }; +} diff --git a/packages/portal/plugins/injectDemoAndApi.ts b/packages/portal/plugins/injectDemoAndApi.ts new file mode 100644 index 0000000000000000000000000000000000000000..1301f91d45c14d76034036d1b6fd64489028fca0 --- /dev/null +++ b/packages/portal/plugins/injectDemoAndApi.ts @@ -0,0 +1,41 @@ +import { type Plugin } from 'vite'; +import { join, dirname } from 'node:path'; +import { existsSync, promises as fsp } from 'node:fs'; +import { getLangByFileName } from '../helper/utils'; + +export function injectDemoAndApi(): Plugin { + return { + name: 'portal:inject-demo-and-api', + enforce: 'pre', + async transform(code, id) { + if (!id.endsWith('.md')) { + return; + } + const imported:{name: string, path: string}[] = []; + let newCode = code.replace(//g, (match, componentName) => { + const demoFile = join(dirname(id), `./__demo__/${componentName}.vue`); + if (existsSync(demoFile)) { + imported.push({ + name: `AutoInject${componentName}`, + path: demoFile, + }); + return ``; + } + return match; + }) + if (imported.length) { + newCode += `\n` + } + const lang = getLangByFileName(id); + const dir = dirname(id); + const files = await fsp.readdir(dir); + for (const file of files) { + const filePath = join(dir, file); + if (filePath.endsWith(`api.${lang.lang}.md`) && await fsp.stat(filePath).then((stat) => stat.isFile())) { + newCode += `\n\n${await fsp.readFile(filePath, 'utf-8')}`; + } + } + return newCode; + }, + }; +} diff --git a/packages/portal/plugins/injectDemoDocs.ts b/packages/portal/plugins/injectDemoDocs.ts new file mode 100644 index 0000000000000000000000000000000000000000..a1c5e9f5604c08d82fd86bff8f903fc96ef68ca3 --- /dev/null +++ b/packages/portal/plugins/injectDemoDocs.ts @@ -0,0 +1,57 @@ +import { type Plugin } from 'vite'; +import { MarkdownItAsync } from 'markdown-it-async'; +import { markdownItPlugins, markdownItOptions } from './markdown/common'; +import matter from 'gray-matter'; + +const parseVueQuery = (id: string) => { + let [file, query] = id.split('?', 2); + if (!query) { + return { file, query: {}, queryExtension: '' }; + } + const queryExtension = query.match(/\.([a-zA-Z0-9]+)$/)?.[1]; + query = queryExtension ? query.slice(0, query.length - queryExtension.length - 1) : query; + const queryObj = query + ? query.split('&').reduce((prev, curr) => { + const [key, value] = curr.split('='); + prev[key] = value || true; + return prev; + }, {} as Record) + : {}; + return { file, query: queryObj, queryExtension }; +}; +export function injectDemoDocs(): Plugin { + const md = new MarkdownItAsync(markdownItOptions); + markdownItPlugins.forEach((plugin) => md.use(plugin)); + const matterReg = /^---[\s\S]*?---/; + return { + name: 'portal:inject-demo-docs', + transform(code, id) { + const { query } = parseVueQuery(id); + if (!query.vue || query.type !== 'docs') { + return; + } + code = code.trimStart(); + const matterMatch = code.match(matterReg); + const matterData = matterMatch ? matter(matterMatch[0]) : null; + const h1Reg = /^#\s+(.+)/gm; + const h1MatchList = Array.from(code.matchAll(h1Reg)); + const __docs: { + title: Record; + description: Record; + } = { title: {}, description: {} }; + if (matterData?.data?.title) { + __docs.title = matterData.data.title; + } + for (let i = 0; i < h1MatchList.length; i++) { + const h1Match = h1MatchList[i]; + const [text, title] = h1Match; + const nextIndex = i + 1 < h1MatchList.length ? h1MatchList[i + 1].index : Infinity; + const desRaw = code.slice(h1Match.index + text.length, nextIndex); + __docs.description[title] = md.render(desRaw); + } + return `export default function (_sfc_main) { +_sfc_main.__docs = ${JSON.stringify(__docs)}; +}`; + }, + }; +} diff --git a/packages/portal/plugins/injectDemoSource.ts b/packages/portal/plugins/injectDemoSource.ts new file mode 100644 index 0000000000000000000000000000000000000000..c5d349afbb24372f4736f4b794da33f27565aac8 --- /dev/null +++ b/packages/portal/plugins/injectDemoSource.ts @@ -0,0 +1,80 @@ +import fsp from 'node:fs/promises'; +import { createFilter, type Plugin } from 'vite'; +import { parse, type SFCBlock } from '@vue/compiler-sfc'; +import { md } from './markdown/common'; + +const VIRTUAL_PREFIX = 'virtual:demo-source:'; +const virtualModules = new Map(); + +const generateCode = (block: SFCBlock) => { + return `<${block.type} ${Object.entries(block.attrs) + .map(([key, value]) => { + if (typeof value === 'string') { + return `${key}="${value}"`; + } else { + return `${key}`; + } + }) + .join(' ')}>${block.content}\n`; +}; +const generateVirtualModule = (source: string) => { + const { descriptor } = parse(source); + let cleanedSource = ''; + + if (descriptor.script) { + cleanedSource = generateCode(descriptor.script); + } + if (descriptor.scriptSetup) { + cleanedSource += generateCode(descriptor.scriptSetup); + } + if (descriptor.template) { + cleanedSource += generateCode(descriptor.template); + } + if (descriptor.styles) { + descriptor.styles.forEach((style) => { + cleanedSource += generateCode(style); + }); + } + cleanedSource = cleanedSource.trimEnd(); + const result = `${md.render(`\`\`\`vue:line-numbers\n${cleanedSource}\n\`\`\``)}`.replace( + /()([\s\S]*?)<\/code><\/pre>/, + (_, pre, codeAttr, codeContent) => { + return `${pre}${codeContent}`; + } + ); + return ``; +}; +export function injectDemoSource(): Plugin { + const filter = createFilter(/opendesign\/src\/.*?\/__demo__\/.+\.vue$/); + return { + name: 'portal:inject-demo-source', + resolveId(id) { + if (virtualModules.has(id)) { + return id; + } + }, + load(id) { + return virtualModules.get(id); + }, + async transform(code, id) { + if (!filter(id) || id.startsWith(VIRTUAL_PREFIX)) { + return; + } + if (await fsp.stat(id).then((stat) => stat.isFile())) { + const source = await fsp.readFile(id, 'utf-8'); + const virtualId = `${VIRTUAL_PREFIX}${id}`; + virtualModules.set(virtualId, generateVirtualModule(source)); + return `${code} +;import _DemoSource from ${JSON.stringify(virtualId)}; +_sfc_main.DemoSource = _DemoSource;`; + } + }, + async handleHotUpdate(ctx) { + const virtualId = `${VIRTUAL_PREFIX}${ctx.file}`; + if (virtualModules.has(virtualId)) { + virtualModules.set(virtualId, generateVirtualModule(await ctx.read())); + ctx.server.watcher.emit('change', virtualId); + } + }, + }; +} diff --git a/packages/portal/plugins/markdown/common.ts b/packages/portal/plugins/markdown/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..d7f49f834c5d019b51b914619e4acff2823bd495 --- /dev/null +++ b/packages/portal/plugins/markdown/common.ts @@ -0,0 +1,24 @@ +import { MarkdownItAsync, type MarkdownItAsyncOptions } from 'markdown-it-async'; +import hljs from 'highlight.js'; +import lineNumber from './lineNumber'; +import popover from './popover'; +import wrapTable from './wrapTable'; +import wrapCodeContainer from './wrapCodeContainer'; + +export function highlight(str: string, lang: string, attrs: string) { + let highlightLang = lang === 'vue' ? 'html' : lang; + const hasLang = hljs.getLanguage(highlightLang); + if (!hasLang) { + highlightLang = 'bash'; + } + return hljs.highlight(str, { language: highlightLang }).value; +} +export const markdownItOptions: MarkdownItAsyncOptions = { + html: true, + linkify: true, + typographer: true, + highlight, +}; +export const markdownItPlugins = [lineNumber, popover, wrapCodeContainer, wrapTable]; +export const md = new MarkdownItAsync(markdownItOptions); +markdownItPlugins.forEach((plugin) => md.use(plugin)); diff --git a/packages/portal/plugins/markdown/lineNumber.ts b/packages/portal/plugins/markdown/lineNumber.ts new file mode 100644 index 0000000000000000000000000000000000000000..7b3cda6eaf2872dca0979355585e3d399cc0ab17 --- /dev/null +++ b/packages/portal/plugins/markdown/lineNumber.ts @@ -0,0 +1,31 @@ +import { type MarkdownItAsync } from 'markdown-it-async'; + +export default function highlightLine(md: MarkdownItAsync) { + const fence = md.renderer.rules.fence; + md.renderer.rules.fence = (tokens, idx, options, env, self) => { + const lineNumberReg = /:line-numbers(=\d+)?/; + const lineNumberMatch = tokens[idx].info.match(lineNumberReg); + let originalCode = ''; + if (lineNumberMatch) { + let start = parseInt(lineNumberMatch[1]?.slice(1) || '1'); + start = Number.isNaN(start) ? 1 : start; + tokens[idx].info = tokens[idx].info.replace(lineNumberMatch[0], ''); + originalCode = fence(tokens, idx, options, env, self); + const codeMatch = originalCode.match(/()([\s\S]*?)(<\/code><\/pre>)/); + if (codeMatch) { + const [, pre, code, post] = codeMatch; + const codeLines = code.split('\n'); + if (codeLines[codeLines.length - 1] === '') { + codeLines.pop(); + } + if (env.portal) { + env.portal.lineNumbers = true; + } else { + env.portal = { lineNumbers: true }; + } + return `${pre}${codeLines.map((line, index) => `${start + index}${line}`).join('\n')}\n${post}`; + } + } + return fence(tokens, idx, options, env, self); + }; +} diff --git a/packages/portal/plugins/markdown/popover.ts b/packages/portal/plugins/markdown/popover.ts new file mode 100644 index 0000000000000000000000000000000000000000..f848fbf377959a155baf3bf3360eb7fc408707b8 --- /dev/null +++ b/packages/portal/plugins/markdown/popover.ts @@ -0,0 +1,42 @@ +import { MarkdownItAsync } from 'markdown-it-async'; +import { escapeHtml } from 'markdown-it/lib/common/utils.mjs'; + +export default function popover(md: MarkdownItAsync) { + // 定义正则表达式,匹配 ^[内容]`提示信息` 语法 + const popoverRegExp = /^\^\[([^\]]*)\](\(normal|primary|success|warning|danger\))?(`[^`]*`)?/; + + md.renderer.rules.popover = function (tokens, idx) { + const token = tokens[idx]; + const content = escapeHtml(token.content); + const info = escapeHtml(token.info); + const target = (icon?: boolean) => + `${icon ? '' : ''}${content}`; + if (!info) { + return target(); + } + return ` + +${info} +`; + }; + + md.inline.ruler.before('emphasis', 'popover', function (state, silent) { + const code = state.src.slice(state.pos, state.posMax); + const matched = code.match(popoverRegExp); + if (!matched) { + return false; + } + if (!silent) { + const token = state.push('popover', 'popover', 0); + token.content = matched[1].replace(/\\\|/g, '|'); + if (!token.meta) { + token.meta = {}; + } + token.meta.color = matched[2] || 'normal'; + token.info = (matched[3] || '').replace(/^`(.*)`$/, '$1'); + token.level = state.level; + state.pos += matched[0].length; + } + return true; + }); +} diff --git a/packages/portal/plugins/markdown/vueMdTranslate.ts b/packages/portal/plugins/markdown/vueMdTranslate.ts new file mode 100644 index 0000000000000000000000000000000000000000..a3d7d82dbe20f8111b9a724be7efd53c3e5fac00 --- /dev/null +++ b/packages/portal/plugins/markdown/vueMdTranslate.ts @@ -0,0 +1,12 @@ +import Markdown from 'unplugin-vue-markdown/vite'; +import { MarkdownItAsync } from 'markdown-it-async'; +import { markdownItOptions, markdownItPlugins } from './common' + +export const markdownItSetup = function (md: MarkdownItAsync) { + markdownItPlugins.forEach((plugin) => md.use(plugin)); +}; +export const plugin = Markdown({ + markdownItOptions, + markdownItSetup, + exclude: /\?vue&type=docs/ +}); diff --git a/packages/portal/plugins/markdown/wrapCodeContainer.ts b/packages/portal/plugins/markdown/wrapCodeContainer.ts new file mode 100644 index 0000000000000000000000000000000000000000..9edd147a9aa1072e73e665ac7519cbe1163188bb --- /dev/null +++ b/packages/portal/plugins/markdown/wrapCodeContainer.ts @@ -0,0 +1,14 @@ +import { MarkdownItAsync } from 'markdown-it-async'; + +export default function wrapCodeContainer(md: MarkdownItAsync) { + const fence = md.renderer.rules.fence; + md.renderer.rules.fence = (tokens, idx, options, env, self) => { + const token = tokens[idx]; + const preCode = fence(tokens, idx, options, env, self); + + return ` + + ${preCode} +`; + }; +} diff --git a/packages/portal/plugins/markdown/wrapTable.ts b/packages/portal/plugins/markdown/wrapTable.ts new file mode 100644 index 0000000000000000000000000000000000000000..abf33bc6f5b35c9e96f028ccb76edbed240bf494 --- /dev/null +++ b/packages/portal/plugins/markdown/wrapTable.ts @@ -0,0 +1,11 @@ +import { type MarkdownItAsync } from 'markdown-it-async'; + +export default function wrapTable(md: MarkdownItAsync) { + md.renderer.rules.table_open = function (tokens, idx, options, env, self) { + const attrs = tokens[idx].attrs || self.renderAttrs(tokens[idx]); + return `
`; + }; + md.renderer.rules.table_close = function () { + return `
`; + }; +} diff --git a/packages/portal/scripts/generateApi.ts b/packages/portal/scripts/generateApi.ts new file mode 100644 index 0000000000000000000000000000000000000000..fd68f2a0d7741892184096e4bb3d121ff4c5c978 --- /dev/null +++ b/packages/portal/scripts/generateApi.ts @@ -0,0 +1,154 @@ +import { glob } from 'glob'; +import { fileURLToPath } from 'url'; +import fsp from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { type ComponentMeta, createChecker } from 'vue-component-meta'; +import { parseMulti } from 'vue-docgen-api'; +import { escapeHtml } from 'markdown-it/lib/common/utils.mjs'; +import parseDefineSlots from './parseDefineSlots'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const base = join(__dirname, '../../opendesign/'); +const srcDir = join(base, 'src'); +const tsConfigPath = join(base, 'tsconfig.json'); + +const checker = createChecker(tsConfigPath, { + forceUseTs: true, + noDeclarations: true, + printer: { newLine: 1 }, +}); +function escapeTableValue(value?: string) { + return escapeHtml(value ? value.replace(/\|/g, '\\|') : ''); +} +function cleanTableData(table: any[][]) { + // 清理表格数据 + table.forEach((row) => { + row.forEach((cell, cellIdx) => { + row[cellIdx] = escapeTableValue(cell); + }); + }); + // 删除空列 + const columnCount = table[0].length; + const emptyIndexes = Array(columnCount).fill(true); + for (let i = 0; i < columnCount; i++) { + for (let j = 1; j < table.length; j++) { + if (table[j][i]) { + emptyIndexes[i] = false; + break; + } + } + } + return table.map((row) => row.filter((_, i) => !emptyIndexes[i])); +} +function markdownTable(table: string[][]) { + let code = ''; + // head + code += '| ' + table[0].join(' | ') + ' |\n'; + code += '| ' + table[0].map(() => '---').join(' | ') + ' |\n'; + // body + for (let i = 1; i < table.length; i++) { + code += '| ' + table[i].join(' | ') + ' |\n'; + } + return code; +} +async function applyTempFixForEventDescriptions(filename: string, componentMeta: ComponentMeta) { + const hasEvents = componentMeta.events.length; + + if (!hasEvents) { + return componentMeta; + } + + try { + const parsedComponentDocs = await parseMulti(filename, { modules: [srcDir], nameFilter: ['default'] }); + componentMeta.events = componentMeta.events.map((event) => { + const parsedEvent = parsedComponentDocs[0].events.find((parsedEvent) => parsedEvent.name === event.name); + + if (parsedEvent) { + event.description = parsedEvent.description; + } + + return event; + }); + } catch { + // noop + } + return componentMeta; +} +async function applyTempFixForSlot(filename: string, componentMeta: ComponentMeta) { + debugger; + const slotReg = /defineSlots<{[^}]+}>\(\)/; + const slotMatch = slotReg.exec(await fsp.readFile(filename, 'utf-8')); + if (!slotMatch) { + return componentMeta; + } + const slotMeta = parseDefineSlots(slotMatch[0]); + const slots = componentMeta.slots.map((slot) => { + const parsedSlot = slotMeta.find((s) => s.name === slot.name); + if (parsedSlot) { + slot.description = parsedSlot.docs.description; + slot.type = parsedSlot.type; + } + return slot; + }); + return { + ...componentMeta, + slots, + }; +} +const terminalWidth = process.stdout.columns || 80; +const progressBarLength = Math.min(Math.floor(terminalWidth / 4), 30); +const pathReg = /\/(O.*)\.vue/; +glob('*/O*.vue', { cwd: srcDir, posix: true }).then((files) => { + files.forEach(async (file, index) => { + const fullPath = join(srcDir, file); + const meta = checker.getComponentMeta(fullPath); + const completedCount = Math.floor(((index + 1) / files.length) * progressBarLength); + const restCount = progressBarLength - completedCount; + const progressBar = `${'█'.repeat(completedCount)}${' '.repeat(restCount)}`; + const percent = (((index + 1) / files.length) * 100).toFixed(0); + + // 输出进度条 + process.stdout.write(`\r[${progressBar}] ${percent}%`); + await applyTempFixForEventDescriptions(fullPath, meta); + await applyTempFixForSlot(fullPath, meta); + const pathMath = file.match(pathReg); + const apiMdPath = join(fullPath, `../__docs__/${pathMath[1]}-api.zh-CN.md`); + let mdContent = `## API ${pathMath[1]}`; + // props + if (meta.props.length) { + let propsData = meta.props + .filter((prop) => !prop.global) + .map((prop) => { + return [ + prop.name, + prop.type, + prop.required ? '√' : '', + prop.description, + prop.tags.map((tag) => `^[${tag.name}]${tag.text ? `\`${tag.text}\`` : ''}`).join(' '), + ]; + }); + propsData.unshift(['属性名', '类型', '必填', '说明', '其它']); + propsData = cleanTableData(propsData); + mdContent = `${mdContent}\n\n### props\n\n${markdownTable(propsData)}`; + } + // events + if (meta.events.length) { + let eventsData = meta.events.map((event) => { + return [event.name, event.signature, event.description, event.tags.map((tag) => `^[${tag.name}]${tag.text ? `\`${tag.text}\`` : ''}`).join(' ')]; + }); + eventsData.unshift(['事件名', '签名', '说明', '其它']); + eventsData = cleanTableData(eventsData); + mdContent = `${mdContent}\n\n### events\n\n${markdownTable(eventsData)}`; + } + // slots + if (meta.slots.length) { + let slotsData = meta.slots.map((slot) => { + return [slot.name, slot.type, slot.description]; + }); + slotsData.unshift(['插槽', '签名', '说明']); + slotsData = cleanTableData(slotsData); + mdContent = `${mdContent}\n\n### slots\n\n${markdownTable(slotsData)}`; + } + await fsp.mkdir(dirname(apiMdPath), { recursive: true }).then(() => fsp.writeFile(apiMdPath, mdContent, { encoding: 'utf-8' })); + }); +}); diff --git a/packages/portal/scripts/parseDefineSlots.ts b/packages/portal/scripts/parseDefineSlots.ts new file mode 100644 index 0000000000000000000000000000000000000000..0698e4b957ee09156d480200eba814e3c1c28b71 --- /dev/null +++ b/packages/portal/scripts/parseDefineSlots.ts @@ -0,0 +1,67 @@ +import ts from 'typescript'; + +interface SlotDefinition { + name: string; + type: string; + docs: { + description: string; + tags: { + name: string; + text: string; + }[]; + }; +} + +export default function parseDefineSlots(code: string): SlotDefinition[] { + const sourceFile = ts.createSourceFile('temp.ts', code, ts.ScriptTarget.Latest, true); + const slots: SlotDefinition[] = []; + + function visit(node: ts.Node) { + // 检测 defineSlots 调用表达式 + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'defineSlots' && node.typeArguments?.length) { + const typeArg = node.typeArguments[0]; + + // 处理内联对象类型 + if (ts.isTypeLiteralNode(typeArg)) { + for (const member of typeArg.members) { + if (ts.isPropertySignature(member)) { + const name = member.name.getText(sourceFile); + const type = member.type?.getText(sourceFile) || 'any'; + const docs = { + description: '', + tags: [], + }; + + // 提取文档注释 + if ((member as any).jsDoc?.length) { + docs.description = (member as any).jsDoc + .map((doc) => { + let comment = doc.getText(sourceFile); + // 清理注释格式 + comment = comment + .replace(/\/\*\*|\*\//g, '') + .replace(/^\s*\*\s?/gm, '') + .replace(/@([a-zA-Z]+)\s+(.*)/gm, (_: string, paramName: string, paramValue: string) => { + docs.tags.push({ + name: paramName, + value: paramValue || '', + }); + return ''; + }) + .trim(); + return comment; + }) + .join('\n'); + } + + slots.push({ name, type, docs }); + } + } + } + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return slots; +} diff --git a/packages/portal/src/App.vue b/packages/portal/src/App.vue index ffa93693495b44cf819880710d0d505f4376929e..73bbf44f699a1b5684b01e06ff31d8abb87a80f9 100644 --- a/packages/portal/src/App.vue +++ b/packages/portal/src/App.vue @@ -1,12 +1,40 @@ @@ -25,6 +53,7 @@ body { left: 0; top: 0; width: 100vw; + box-sizing: border-box; height: var(--app-header-height); z-index: 10; } @@ -41,8 +70,11 @@ body { } .app-body { margin-top: var(--app-header-height); - margin-left: var(--app-aside-width); min-height: calc(100vh - 48px); background-color: var(--o-color-fill1); + + &.has-sidebar { + margin-left: var(--app-aside-width); + } } diff --git a/packages/portal/src/assets/style/markdown.scss b/packages/portal/src/assets/style/markdown.scss new file mode 100644 index 0000000000000000000000000000000000000000..61a42f3893f062ac169dcb92c403bfdcb008a942 --- /dev/null +++ b/packages/portal/src/assets/style/markdown.scss @@ -0,0 +1,6 @@ +:not(pre) > code { + border-radius: 4px; + padding: 0.1em 0.2em; + margin: 0 0.2em; + background-color: var(--o-color-control1-light); +} diff --git a/packages/portal/src/assets/style/style.scss b/packages/portal/src/assets/style/style.scss index 01c58942da7b1d4386226f7cfcc060946fa84820..1edf2c699eda8770708c1bb4c6cb06b98a166dd0 100644 --- a/packages/portal/src/assets/style/style.scss +++ b/packages/portal/src/assets/style/style.scss @@ -1,3 +1,5 @@ +@use './markdown.scss'; + body { font-family: Inter, Avenir, Helvetica, Arial, sans-serif; font-size: 16px; @@ -6,7 +8,7 @@ body { color-scheme: light dark; - color: var(--o-color-info1); + color: var(--o-color-info2); background-color: var(--o-color-fill1); font-synthesis: none; @@ -100,3 +102,7 @@ section { border-color: #aaa; } } + +.markdown-body { + padding: 8px 24px; +} diff --git a/packages/portal/src/components/CodeContainer.vue b/packages/portal/src/components/CodeContainer.vue new file mode 100644 index 0000000000000000000000000000000000000000..b0e9b8ee02a06268d9f33bd15afcc070d670ca5c --- /dev/null +++ b/packages/portal/src/components/CodeContainer.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/packages/portal/src/components/DemoContainer.vue b/packages/portal/src/components/DemoContainer.vue new file mode 100644 index 0000000000000000000000000000000000000000..6e0365fb6047f6af1dc80bd08960434574a07d18 --- /dev/null +++ b/packages/portal/src/components/DemoContainer.vue @@ -0,0 +1,90 @@ + + + + diff --git a/packages/portal/src/components/TheAside.vue b/packages/portal/src/components/TheAside.vue index 1c5bbb76efedc81ebaab82d262363c7a071150db..8cb335eeffc7a46cd594bb9183899e5dfda3cacd 100644 --- a/packages/portal/src/components/TheAside.vue +++ b/packages/portal/src/components/TheAside.vue @@ -1,42 +1,24 @@