feat(@vben/docs): preview components are supported within documents (#4250)
This commit is contained in:
43
docs/.vitepress/components/demo-preview.vue
Normal file
43
docs/.vitepress/components/demo-preview.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import PreviewGroup from './preview-group.vue';
|
||||
|
||||
interface Props {
|
||||
files?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { files: '() => []' });
|
||||
|
||||
const parsedFiles = computed(() => {
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(props.files ?? ''));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border-border shadow-float relative rounded-xl border">
|
||||
<div
|
||||
class="not-prose relative w-full overflow-x-auto rounded-t-lg px-4 py-6"
|
||||
>
|
||||
<div class="flex w-full max-w-[700px] px-2">
|
||||
<slot v-if="parsedFiles.length > 0"></slot>
|
||||
<div v-else class="text-destructive text-sm">
|
||||
<span class="bg-destructive text-foreground rounded-sm px-1 py-1">
|
||||
ERROR:
|
||||
</span>
|
||||
The preview directory does not exist. Please check the 'dir'
|
||||
parameter.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PreviewGroup v-if="parsedFiles.length > 0" :files="parsedFiles">
|
||||
<template v-for="file in parsedFiles" #[file]>
|
||||
<slot :name="file"></slot>
|
||||
</template>
|
||||
</PreviewGroup>
|
||||
</div>
|
||||
</template>
|
1
docs/.vitepress/components/index.ts
Normal file
1
docs/.vitepress/components/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as DemoPreview } from './demo-preview.vue';
|
108
docs/.vitepress/components/preview-group.vue
Normal file
108
docs/.vitepress/components/preview-group.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useSlots } from 'vue';
|
||||
|
||||
import { VbenTooltip } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { Code } from 'lucide-vue-next';
|
||||
import {
|
||||
TabsContent,
|
||||
TabsIndicator,
|
||||
TabsList,
|
||||
TabsRoot,
|
||||
TabsTrigger,
|
||||
} from 'radix-vue';
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
files?: string[];
|
||||
}>(),
|
||||
{ files: () => [] },
|
||||
);
|
||||
|
||||
const open = ref(false);
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
const tabs = computed(() => {
|
||||
return props.files.map((file) => {
|
||||
return {
|
||||
component: slots[file],
|
||||
label: file,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const currentTab = ref('index.vue');
|
||||
|
||||
const toggleOpen = () => {
|
||||
open.value = !open.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsRoot
|
||||
v-model="currentTab"
|
||||
class="bg-background-deep border-border overflow-hidden rounded-b-xl border-t"
|
||||
@update:model-value="open = true"
|
||||
>
|
||||
<div class="border-border bg-background flex border-b-2 pr-2">
|
||||
<div class="flex w-full items-center justify-between text-[13px]">
|
||||
<TabsList class="relative flex">
|
||||
<template v-if="open">
|
||||
<TabsIndicator
|
||||
class="absolute bottom-0 left-0 h-[2px] w-[--radix-tabs-indicator-size] translate-x-[--radix-tabs-indicator-position] rounded-full transition-[width,transform] duration-300"
|
||||
>
|
||||
<div class="size-full bg-[var(--vp-c-indigo-1)]"></div>
|
||||
</TabsIndicator>
|
||||
<TabsTrigger
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="index"
|
||||
:value="tab.label"
|
||||
class="border-box text-foreground px-4 py-3 data-[state=active]:text-[var(--vp-c-indigo-1)]"
|
||||
tabindex="-1"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</TabsTrigger>
|
||||
</template>
|
||||
</TabsList>
|
||||
|
||||
<div
|
||||
:class="{
|
||||
'py-2': !open,
|
||||
}"
|
||||
class="flex items-center"
|
||||
>
|
||||
<VbenTooltip side="top">
|
||||
<template #trigger>
|
||||
<Code
|
||||
class="hover:bg-accent size-6.5 cursor-pointer rounded-full p-1.5"
|
||||
@click="toggleOpen"
|
||||
/>
|
||||
</template>
|
||||
{{ open ? 'Collapse code' : 'Expand code' }}
|
||||
</VbenTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:class="`${open ? 'h-[unset] max-h-[80vh]' : 'h-0'}`"
|
||||
class="block overflow-y-scroll bg-[var(--vp-code-block-bg)] transition-all duration-300"
|
||||
>
|
||||
<TabsContent
|
||||
v-for="tab in tabs"
|
||||
:key="tab.label"
|
||||
:value="tab.label"
|
||||
as-child
|
||||
class="rounded-xl"
|
||||
>
|
||||
<div class="text-foreground relative rounded-xl">
|
||||
<component :is="tab.component" class="border-0" />
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</TabsRoot>
|
||||
</template>
|
@@ -203,7 +203,7 @@ function nav(): DefaultTheme.NavItem[] {
|
||||
},
|
||||
{
|
||||
link: '/commercial/technical-support',
|
||||
text: '🦄 Technical Support',
|
||||
text: '🦄 Tech Support',
|
||||
},
|
||||
{
|
||||
link: '/sponsor/personal',
|
||||
|
@@ -2,12 +2,12 @@ import { withPwa } from '@vite-pwa/vitepress';
|
||||
import { defineConfigWithTheme } from 'vitepress';
|
||||
|
||||
import { en } from './en.mts';
|
||||
import { shard } from './shard.mts';
|
||||
import { shared } from './shared.mts';
|
||||
import { zh } from './zh.mts';
|
||||
|
||||
export default withPwa(
|
||||
defineConfigWithTheme({
|
||||
...shard,
|
||||
...shared,
|
||||
locales: {
|
||||
en: {
|
||||
label: 'English',
|
||||
|
135
docs/.vitepress/config/plugins/demo-preview.ts
Normal file
135
docs/.vitepress/config/plugins/demo-preview.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { MarkdownEnv, MarkdownRenderer } from 'vitepress';
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export const rawPathRegexp =
|
||||
// eslint-disable-next-line regexp/no-super-linear-backtracking, regexp/strict
|
||||
/^(.+?(?:\.([\da-z]+))?)(#[\w-]+)?(?: ?{(\d+(?:[,-]\d+)*)? ?(\S+)?})? ?(?:\[(.+)])?$/;
|
||||
|
||||
function rawPathToToken(rawPath: string) {
|
||||
const [
|
||||
filepath = '',
|
||||
extension = '',
|
||||
region = '',
|
||||
lines = '',
|
||||
lang = '',
|
||||
rawTitle = '',
|
||||
] = (rawPathRegexp.exec(rawPath) || []).slice(1);
|
||||
|
||||
const title = rawTitle || filepath.split('/').pop() || '';
|
||||
|
||||
return { extension, filepath, lang, lines, region, title };
|
||||
}
|
||||
|
||||
export const demoPreviewPlugin = (md: MarkdownRenderer) => {
|
||||
md.core.ruler.after('inline', 'demo-preview', (state) => {
|
||||
const insertComponentImport = (importString: string) => {
|
||||
const index = state.tokens.findIndex(
|
||||
(i) => i.type === 'html_block' && i.content.match(/<script setup>/g),
|
||||
);
|
||||
if (index === -1) {
|
||||
const importComponent = new state.Token('html_block', '', 0);
|
||||
importComponent.content = `<script setup>\n${importString}\n</script>\n`;
|
||||
state.tokens.splice(0, 0, importComponent);
|
||||
} else {
|
||||
if (state.tokens[index]) {
|
||||
const content = state.tokens[index].content;
|
||||
state.tokens[index].content = content.replace(
|
||||
'</script>',
|
||||
`${importString}\n</script>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Define the regular expression to match the desired pattern
|
||||
const regex = /<DemoPreview[^>]*\sdir="([^"]*)"/g;
|
||||
// Iterate through the Markdown content and replace the pattern
|
||||
state.src = state.src.replaceAll(regex, (_match, dir) => {
|
||||
const componentDir = join(process.cwd(), 'src', dir);
|
||||
|
||||
let childFiles: string[] = [];
|
||||
let dirExists = true;
|
||||
|
||||
try {
|
||||
childFiles =
|
||||
readdirSync(componentDir, {
|
||||
encoding: 'utf8',
|
||||
recursive: false,
|
||||
withFileTypes: false,
|
||||
}) || [];
|
||||
} catch {
|
||||
dirExists = false;
|
||||
}
|
||||
|
||||
if (!dirExists) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const uniqueWord = generateContentHash(componentDir);
|
||||
|
||||
const ComponentName = `DemoComponent_${uniqueWord}`;
|
||||
insertComponentImport(
|
||||
`import ${ComponentName} from '${componentDir}/index.vue'`,
|
||||
);
|
||||
const { path: _path } = state.env as MarkdownEnv;
|
||||
|
||||
const index = state.tokens.findIndex((i) => i.content.match(regex));
|
||||
|
||||
if (!state.tokens[index]) {
|
||||
return '';
|
||||
}
|
||||
|
||||
state.tokens[index].content =
|
||||
`<DemoPreview files="${encodeURIComponent(JSON.stringify(childFiles))}" ><${ComponentName}/>
|
||||
`;
|
||||
|
||||
const _dummyToken = new state.Token('', '', 0);
|
||||
const tokenArray: Array<typeof _dummyToken> = [];
|
||||
childFiles.forEach((filename) => {
|
||||
// const slotName = filename.replace(extname(filename), '');
|
||||
|
||||
const templateStart = new state.Token('html_inline', '', 0);
|
||||
templateStart.content = `<template #${filename}>`;
|
||||
tokenArray.push(templateStart);
|
||||
|
||||
const resolvedPath = join(componentDir, filename);
|
||||
|
||||
const { extension, filepath, lang, lines, title } =
|
||||
rawPathToToken(resolvedPath);
|
||||
// Add code tokens for each line
|
||||
const token = new state.Token('fence', 'code', 0);
|
||||
token.info = `${lang || extension}${lines ? `{${lines}}` : ''}${
|
||||
title ? `[${title}]` : ''
|
||||
}`;
|
||||
|
||||
token.content = `<<< ${filepath}`;
|
||||
(token as any).src = [resolvedPath];
|
||||
tokenArray.push(token);
|
||||
|
||||
const templateEnd = new state.Token('html_inline', '', 0);
|
||||
templateEnd.content = '</template>';
|
||||
tokenArray.push(templateEnd);
|
||||
});
|
||||
const endTag = new state.Token('html_inline', '', 0);
|
||||
endTag.content = '</DemoPreview>';
|
||||
tokenArray.push(endTag);
|
||||
|
||||
state.tokens.splice(index + 1, 0, ...tokenArray);
|
||||
|
||||
// console.log(
|
||||
// state.md.renderer.render(state.tokens, state?.options ?? [], state.env),
|
||||
// );
|
||||
return '';
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
function generateContentHash(input: string, length: number = 10): string {
|
||||
// 使用 SHA-256 生成哈希值
|
||||
const hash = crypto.createHash('sha256').update(input).digest('hex');
|
||||
|
||||
// 将哈希值转换为 Base36 编码,并取指定长度的字符作为结果
|
||||
return Number.parseInt(hash, 16).toString(36).slice(0, length);
|
||||
}
|
@@ -1,4 +1,5 @@
|
||||
import type { PwaOptions } from '@vite-pwa/vitepress';
|
||||
import type { HeadConfig } from 'vitepress';
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
@@ -6,12 +7,20 @@ import {
|
||||
GitChangelog,
|
||||
GitChangelogMarkdownSection,
|
||||
} from '@nolebase/vitepress-plugin-git-changelog/vite';
|
||||
import { defineConfig, type HeadConfig } from 'vitepress';
|
||||
import tailwind from 'tailwindcss';
|
||||
import { defineConfig, postcssIsolateStyles } from 'vitepress';
|
||||
|
||||
import { demoPreviewPlugin } from './plugins/demo-preview';
|
||||
import { search as zhSearch } from './zh.mts';
|
||||
|
||||
export const shard = defineConfig({
|
||||
export const shared = defineConfig({
|
||||
appearance: 'dark',
|
||||
head: head(),
|
||||
markdown: {
|
||||
preConfig(md) {
|
||||
md.use(demoPreviewPlugin);
|
||||
},
|
||||
},
|
||||
pwa: pwa(),
|
||||
srcDir: 'src',
|
||||
themeConfig: {
|
||||
@@ -36,11 +45,34 @@ export const shard = defineConfig({
|
||||
chunkSizeWarningLimit: Infinity,
|
||||
minify: 'terser',
|
||||
},
|
||||
css: {
|
||||
postcss: {
|
||||
plugins: [
|
||||
tailwind(),
|
||||
postcssIsolateStyles({ includeFiles: [/vp-doc\.css/] }),
|
||||
],
|
||||
},
|
||||
},
|
||||
json: {
|
||||
stringify: true,
|
||||
},
|
||||
plugins: [
|
||||
GitChangelog({
|
||||
mapAuthors: [
|
||||
{
|
||||
mapByNameAliases: ['Vben'],
|
||||
name: 'vben',
|
||||
username: 'anncwb',
|
||||
},
|
||||
{
|
||||
name: 'vince',
|
||||
username: 'vince292007',
|
||||
},
|
||||
{
|
||||
name: 'Li Kui',
|
||||
username: 'likui628',
|
||||
},
|
||||
],
|
||||
repoURL: () => 'https://github.com/vbenjs/vue-vben-admin',
|
||||
}),
|
||||
GitChangelogMarkdownSection(),
|
@@ -38,6 +38,7 @@ export const zh = defineConfig({
|
||||
|
||||
sidebar: {
|
||||
'/commercial/': { base: '/commercial/', items: sidebarCommercial() },
|
||||
'/components/': { base: '/components/', items: sidebarComponents() },
|
||||
'/guide/': { base: '/guide/', items: sidebarGuide() },
|
||||
},
|
||||
sidebarMenuLabel: '菜单',
|
||||
@@ -60,6 +61,11 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
},
|
||||
{ link: 'introduction/quick-start', text: '快速开始' },
|
||||
{ link: 'introduction/thin', text: '精简版本' },
|
||||
{
|
||||
base: '/',
|
||||
link: 'components/introduction',
|
||||
text: '组件文档',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -117,7 +123,7 @@ function sidebarCommercial(): DefaultTheme.SidebarItem[] {
|
||||
return [
|
||||
{
|
||||
link: 'community',
|
||||
text: '社区交流',
|
||||
text: '社区',
|
||||
},
|
||||
{
|
||||
link: 'technical-support',
|
||||
@@ -130,6 +136,30 @@ function sidebarCommercial(): DefaultTheme.SidebarItem[] {
|
||||
];
|
||||
}
|
||||
|
||||
function sidebarComponents(): DefaultTheme.SidebarItem[] {
|
||||
return [
|
||||
{
|
||||
text: '组件',
|
||||
items: [
|
||||
{
|
||||
link: 'introduction',
|
||||
text: '介绍',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
collapsed: false,
|
||||
text: '通用组件',
|
||||
items: [
|
||||
{
|
||||
link: 'common-ui/vben-modal',
|
||||
text: 'Modal 弹窗',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function nav(): DefaultTheme.NavItem[] {
|
||||
return [
|
||||
{
|
||||
@@ -138,28 +168,10 @@ function nav(): DefaultTheme.NavItem[] {
|
||||
{
|
||||
link: '/guide/introduction/vben',
|
||||
text: '指南',
|
||||
// items: [
|
||||
// {
|
||||
// link: '/guide/introduction/vben',
|
||||
// text: '简介',
|
||||
// },
|
||||
// {
|
||||
// link: '/guide/essentials/concept',
|
||||
// text: '基础',
|
||||
// },
|
||||
// {
|
||||
// link: '/guide/in-depth/layout',
|
||||
// text: '深入',
|
||||
// },
|
||||
// {
|
||||
// link: '/guide/project/standard',
|
||||
// text: '工程',
|
||||
// },
|
||||
// {
|
||||
// link: '/guide/other/project-update',
|
||||
// text: '其他',
|
||||
// },
|
||||
// ],
|
||||
},
|
||||
{
|
||||
link: '/components/introduction',
|
||||
text: '组件',
|
||||
},
|
||||
{
|
||||
text: '历史版本',
|
||||
@@ -234,7 +246,7 @@ function nav(): DefaultTheme.NavItem[] {
|
||||
},
|
||||
{
|
||||
link: '/commercial/community',
|
||||
text: '👨👦👦 社区交流',
|
||||
text: '👨👦👦 社区',
|
||||
// items: [
|
||||
// {
|
||||
// link: 'https://qun.qq.com/qqweb/qunpro/share?_wv=3&_wwv=128&appChannel=share&inviteCode=22ySzj7pKiw&businessType=9&from=246610&biz=ka&mainSourceId=share&subSourceId=others&jumpsource=shorturl#/pc',
|
||||
|
@@ -1,9 +1,10 @@
|
||||
// https://vitepress.dev/guide/custom-theme
|
||||
import type { Theme } from 'vitepress';
|
||||
import type { EnhanceAppContext, Theme } from 'vitepress';
|
||||
|
||||
import { NolebaseGitChangelogPlugin } from '@nolebase/vitepress-plugin-git-changelog/client';
|
||||
import DefaultTheme from 'vitepress/theme';
|
||||
|
||||
import { DemoPreview } from '../components';
|
||||
import SiteLayout from './components/site-layout.vue';
|
||||
import VbenContributors from './components/vben-contributors.vue';
|
||||
import { initHmPlugin } from './plugins/hm';
|
||||
@@ -13,9 +14,10 @@ import './styles';
|
||||
import '@nolebase/vitepress-plugin-git-changelog/client/style.css';
|
||||
|
||||
export default {
|
||||
enhanceApp({ app }) {
|
||||
// ...
|
||||
enhanceApp(ctx: EnhanceAppContext) {
|
||||
const { app } = ctx;
|
||||
app.component('VbenContributors', VbenContributors);
|
||||
app.component('DemoPreview', DemoPreview);
|
||||
app.use(NolebaseGitChangelogPlugin);
|
||||
// 百度统计
|
||||
initHmPlugin();
|
||||
|
@@ -1,2 +1,3 @@
|
||||
import './variables.css';
|
||||
import './base.css';
|
||||
import '@vben/styles';
|
||||
|
Reference in New Issue
Block a user