round="pill"
+round="{{ r }}px"
+([\s\S]*?)<\/code><\/pre>/,
+ (_, pre, codeAttr, codeContent) => {
+ return `${pre}${codeContent}`;
+ }
+ );
+ return `${result}`;
+};
+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 `
+${target(true)}
+${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 @@
+
+
+
+
+
+
+
+
+ {{ props.lang }}
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+ {{ props.demo.__docs.title[locale] }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@