mirror of
https://github.com/ant-design/ant-design.git
synced 2026-02-09 02:49:18 +08:00
docs: Base AI generate theme (#54412)
* chore: init link * docs: init * chore: adjust logic * chore: theme connect * chore: add prompt talk * chore: support chat prompt * chore: api call * chore: sse * feat: site alg * chore: init layer * chore: fix order * chore: update ts def * chore: tmp of it * chore: init script * chore: add generate script * chore: fix script * chore: llms semantic * chore: update desc * chore: fix lint * chore: fix lint
This commit is contained in:
@@ -1,15 +1,21 @@
|
||||
import React from 'react';
|
||||
import { StyleProvider } from '@ant-design/cssinjs';
|
||||
import { Flex, Skeleton, Spin } from 'antd';
|
||||
import { useLocation } from 'dumi';
|
||||
|
||||
import { Common } from './styles';
|
||||
|
||||
const Loading: React.FC = () => {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
let loadingNode: React.ReactNode = null;
|
||||
|
||||
if (
|
||||
pathname.startsWith('/components') ||
|
||||
pathname.startsWith('/docs') ||
|
||||
pathname.startsWith('/changelog')
|
||||
) {
|
||||
return (
|
||||
loadingNode = (
|
||||
<div style={{ maxWidth: '70vw', width: '100%', margin: '80px auto 0', textAlign: 'center' }}>
|
||||
<img
|
||||
src="https://gw.alipayobjects.com/zos/rmsportal/KDpgvguMpGfqaHPjicRK.svg"
|
||||
@@ -22,12 +28,26 @@ const Loading: React.FC = () => {
|
||||
<Skeleton active paragraph={{ rows: 4 }} style={{ marginTop: 32 }} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
loadingNode = (
|
||||
<Flex
|
||||
justify="center"
|
||||
align="center"
|
||||
gap="small"
|
||||
style={{ width: '100%', margin: '120px 0' }}
|
||||
>
|
||||
<Spin size="large" />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading 组件独立于 GlobalLayout,而它也会影响站点样式。
|
||||
// 所以我们这边需要 hardcode 一下启动 layer。
|
||||
return (
|
||||
<Flex justify="center" align="center" gap="small" style={{ width: '100%', margin: '120px 0' }}>
|
||||
<Spin size="large" />
|
||||
</Flex>
|
||||
<StyleProvider layer>
|
||||
<Common />
|
||||
{loadingNode}
|
||||
</StyleProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
108
.dumi/theme/common/ThemeSwitch/PromptDrawer.tsx
Normal file
108
.dumi/theme/common/ThemeSwitch/PromptDrawer.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { AntDesignOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { Bubble, Sender } from '@ant-design/x';
|
||||
import { Drawer, Flex, GetProp, Typography } from 'antd';
|
||||
|
||||
import useLocale from '../../../hooks/useLocale';
|
||||
import type { SiteContextProps } from '../../../theme/slots/SiteContext';
|
||||
import usePromptTheme from './usePromptTheme';
|
||||
|
||||
const locales = {
|
||||
cn: {
|
||||
title: 'AI 生成主题',
|
||||
finishTips: '生成完成,对话以重新生成。',
|
||||
},
|
||||
en: {
|
||||
title: 'AI Theme Generator',
|
||||
finishTips: 'Completed. Regenerate by start a new conversation.',
|
||||
},
|
||||
};
|
||||
|
||||
export interface PromptDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onThemeChange?: (themeConfig: SiteContextProps['dynamicTheme']) => void;
|
||||
}
|
||||
|
||||
const PromptDrawer: React.FC<PromptDrawerProps> = ({ open, onClose, onThemeChange }) => {
|
||||
const [locale] = useLocale(locales);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const senderRef = useRef<any>(null);
|
||||
|
||||
const [submitPrompt, loading, prompt, resText, cancelRequest] = usePromptTheme(onThemeChange);
|
||||
|
||||
const handleSubmit = (value: string) => {
|
||||
submitPrompt(value);
|
||||
setInputValue('');
|
||||
};
|
||||
|
||||
const handleAfterOpenChange = (isOpen: boolean) => {
|
||||
if (isOpen && senderRef.current) {
|
||||
// Focus the Sender component when drawer opens
|
||||
senderRef.current.focus?.();
|
||||
}
|
||||
};
|
||||
|
||||
const items = React.useMemo<GetProp<typeof Bubble.List, 'items'>>(() => {
|
||||
if (!prompt) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const nextItems: GetProp<typeof Bubble.List, 'items'> = [
|
||||
{
|
||||
placement: 'end',
|
||||
content: prompt,
|
||||
avatar: { icon: <UserOutlined /> },
|
||||
shape: 'corner',
|
||||
},
|
||||
{
|
||||
placement: 'start',
|
||||
content: resText,
|
||||
avatar: { icon: <AntDesignOutlined /> },
|
||||
loading: !resText,
|
||||
messageRender: (content: string) => (
|
||||
<Typography>
|
||||
<pre style={{ margin: 0 }}>{content}</pre>
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (!loading) {
|
||||
nextItems.push({
|
||||
placement: 'start',
|
||||
content: locale.finishTips,
|
||||
avatar: { icon: <AntDesignOutlined /> },
|
||||
shape: 'corner',
|
||||
});
|
||||
}
|
||||
|
||||
return nextItems;
|
||||
}, [prompt, resText, loading]);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={locale.title}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width={480}
|
||||
placement="right"
|
||||
afterOpenChange={handleAfterOpenChange}
|
||||
>
|
||||
<Flex vertical style={{ height: '100%' }}>
|
||||
<Bubble.List style={{ flex: 1, overflow: 'auto' }} items={items} />
|
||||
<Sender
|
||||
ref={senderRef}
|
||||
style={{ flex: 0 }}
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSubmit={handleSubmit}
|
||||
loading={loading}
|
||||
onCancel={cancelRequest}
|
||||
/>
|
||||
</Flex>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default PromptDrawer;
|
||||
@@ -1,5 +1,11 @@
|
||||
import React, { use, useRef } from 'react';
|
||||
import { BgColorsOutlined, LinkOutlined, SmileOutlined, SunOutlined } from '@ant-design/icons';
|
||||
import React, { use, useRef, useState } from 'react';
|
||||
import {
|
||||
BgColorsOutlined,
|
||||
LinkOutlined,
|
||||
ShopOutlined,
|
||||
SmileOutlined,
|
||||
SunOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Badge, Button, Dropdown } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { CompactTheme, DarkTheme } from 'antd-token-previewer/es/icons';
|
||||
@@ -10,6 +16,7 @@ import type { SiteContextProps } from '../../slots/SiteContext';
|
||||
import SiteContext from '../../slots/SiteContext';
|
||||
import { getLocalizedPathname, isZhCN } from '../../utils';
|
||||
import Link from '../Link';
|
||||
import PromptDrawer from './PromptDrawer';
|
||||
import ThemeIcon from './ThemeIcon';
|
||||
|
||||
export type ThemeName = 'light' | 'dark' | 'compact' | 'motion-off' | 'happy-work';
|
||||
@@ -20,9 +27,10 @@ export interface ThemeSwitchProps {
|
||||
|
||||
const ThemeSwitch: React.FC<ThemeSwitchProps> = () => {
|
||||
const { pathname, search } = useLocation();
|
||||
const { theme, updateSiteConfig } = use<SiteContextProps>(SiteContext);
|
||||
const { theme, updateSiteConfig, dynamicTheme } = use<SiteContextProps>(SiteContext);
|
||||
const toggleAnimationTheme = useThemeAnimation();
|
||||
const lastThemeKey = useRef<string>(theme.includes('dark') ? 'dark' : 'light');
|
||||
const [isMarketDrawerOpen, setIsMarketDrawerOpen] = useState(false);
|
||||
|
||||
const badge = <Badge color="blue" style={{ marginTop: -1 }} />;
|
||||
|
||||
@@ -61,6 +69,12 @@ const ThemeSwitch: React.FC<ThemeSwitchProps> = () => {
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
id: 'app.theme.switch.market',
|
||||
icon: <ShopOutlined />,
|
||||
key: 'market',
|
||||
showBadge: () => !!dynamicTheme,
|
||||
},
|
||||
{
|
||||
id: 'app.footer.theme',
|
||||
icon: <BgColorsOutlined />,
|
||||
@@ -95,8 +109,25 @@ const ThemeSwitch: React.FC<ThemeSwitchProps> = () => {
|
||||
|
||||
// 处理主题切换
|
||||
const handleThemeChange = (key: string, domEvent: React.MouseEvent<HTMLElement, MouseEvent>) => {
|
||||
// 主题编辑器特殊处理
|
||||
if (key === 'theme-editor') {
|
||||
// 查找对应的选项配置
|
||||
const option = themeOptions.find((opt) => opt.key === key);
|
||||
|
||||
// 链接类型的菜单项特殊处理,不执行主题切换逻辑
|
||||
if (option?.isLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Market 选项特殊处理
|
||||
if (key === 'market') {
|
||||
// 如果已经有动态主题,点击时清除动态主题
|
||||
if (dynamicTheme) {
|
||||
updateSiteConfig({
|
||||
dynamicTheme: undefined,
|
||||
});
|
||||
} else {
|
||||
// 否则打开 Drawer 生成新主题
|
||||
setIsMarketDrawerOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -131,9 +162,21 @@ const ThemeSwitch: React.FC<ThemeSwitchProps> = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items, onClick }} arrow={{ pointAtCenter: true }} placement="bottomRight">
|
||||
<Button type="text" icon={<ThemeIcon />} style={{ fontSize: 16 }} />
|
||||
</Dropdown>
|
||||
<>
|
||||
<Dropdown menu={{ items, onClick }} arrow={{ pointAtCenter: true }} placement="bottomRight">
|
||||
<Button type="text" icon={<ThemeIcon />} style={{ fontSize: 16 }} />
|
||||
</Dropdown>
|
||||
|
||||
<PromptDrawer
|
||||
open={isMarketDrawerOpen}
|
||||
onClose={() => setIsMarketDrawerOpen(false)}
|
||||
onThemeChange={(nextTheme) => {
|
||||
updateSiteConfig({
|
||||
dynamicTheme: nextTheme,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
131
.dumi/theme/common/ThemeSwitch/usePromptTheme.ts
Normal file
131
.dumi/theme/common/ThemeSwitch/usePromptTheme.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/* eslint-disable compat/compat */
|
||||
import { useRef, useState } from 'react';
|
||||
import { XStream } from '@ant-design/x';
|
||||
|
||||
import type { SiteContextProps } from '../../../theme/slots/SiteContext';
|
||||
|
||||
const fetchTheme = async (
|
||||
prompt: string,
|
||||
update: (currentFullContent: string) => void,
|
||||
abortSignal?: AbortSignal,
|
||||
) => {
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: prompt,
|
||||
userId: 'AntDesignSite',
|
||||
}),
|
||||
signal: abortSignal,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.x.ant.design/api/agent_tbox_antd', options);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('Response body is null');
|
||||
}
|
||||
|
||||
let fullContent = '';
|
||||
|
||||
for await (const chunk of XStream({
|
||||
readableStream: response.body,
|
||||
})) {
|
||||
if (chunk.event === 'message') {
|
||||
const data = JSON.parse(chunk.data) as {
|
||||
lane: string;
|
||||
payload: string;
|
||||
};
|
||||
|
||||
const payload = JSON.parse(data.payload) as {
|
||||
text: string;
|
||||
};
|
||||
|
||||
fullContent += payload.text || '';
|
||||
update(fullContent);
|
||||
}
|
||||
}
|
||||
|
||||
return fullContent;
|
||||
} catch (error) {
|
||||
console.error('Error in fetchTheme:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Remove '```json' code block from the response
|
||||
function getJsonText(raw: string, rmComment = false): string {
|
||||
const replaced = raw.trim().replace(/^```json\s*|\s*```$/g, '');
|
||||
|
||||
return rmComment ? replaced.replace(/\/\/.*|\/\*[\s\S]*?\*\//g, '').trim() : replaced;
|
||||
}
|
||||
|
||||
export default function usePromptTheme(
|
||||
onThemeChange?: (themeConfig: SiteContextProps['dynamicTheme']) => void,
|
||||
) {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [resText, setResText] = useState('');
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const submitPrompt = async (nextPrompt: string) => {
|
||||
if (!nextPrompt.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPrompt(nextPrompt);
|
||||
|
||||
// Cancel previous request if it exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new AbortController for this request
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
setLoading(true);
|
||||
setResText('');
|
||||
|
||||
try {
|
||||
const data = await fetchTheme(
|
||||
nextPrompt,
|
||||
(currentContent) => {
|
||||
setResText(currentContent);
|
||||
},
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
// Handle the response
|
||||
if (data && onThemeChange) {
|
||||
const nextConfig = JSON.parse(getJsonText(data, true));
|
||||
onThemeChange(nextConfig);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
console.warn('Request was aborted');
|
||||
} else {
|
||||
console.error('Failed to generate theme:', error);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const cancelRequest = () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return [submitPrompt, loading, prompt, getJsonText(resText), cancelRequest] as const;
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export default () => {
|
||||
const { anchorTop } = useTheme();
|
||||
|
||||
React.useInsertionEffect(() => {
|
||||
updateCSS(`@layer global, antd;`, 'site-global', {
|
||||
updateCSS(`@layer theme, base, global, antd, components, utilities;`, 'site-global', {
|
||||
prepend: true,
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -13,7 +13,6 @@ import useLocation from '../../../hooks/useLocation';
|
||||
import GlobalStyles from '../../common/GlobalStyles';
|
||||
import Header from '../../slots/Header';
|
||||
import SiteContext from '../../slots/SiteContext';
|
||||
|
||||
import IndexLayout from '../IndexLayout';
|
||||
import ResourceLayout from '../ResourceLayout';
|
||||
import SidebarLayout from '../SidebarLayout';
|
||||
@@ -84,7 +83,7 @@ const DocLayout: React.FC = () => {
|
||||
if (pathname.startsWith('/docs/resource')) {
|
||||
return <ResourceLayout>{outlet}</ResourceLayout>;
|
||||
}
|
||||
if (pathname.startsWith('/theme-editor')) {
|
||||
if (pathname.startsWith('/theme-editor') || pathname.startsWith('/theme-market')) {
|
||||
return outlet;
|
||||
}
|
||||
return <SidebarLayout>{outlet}</SidebarLayout>;
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@ant-design/cssinjs';
|
||||
import { HappyProvider } from '@ant-design/happy-work-theme';
|
||||
import { getSandpackCssText } from '@codesandbox/sandpack-react';
|
||||
import { theme as antdTheme, App } from 'antd';
|
||||
import { theme as antdTheme, App, ConfigProvider } from 'antd';
|
||||
import type { MappingAlgorithm } from 'antd';
|
||||
import type { DirectionType, ThemeConfig } from 'antd/es/config-provider';
|
||||
import { createSearchParams, useOutlet, useSearchParams, useServerInsertedHTML } from 'dumi';
|
||||
@@ -21,13 +21,12 @@ import { DarkContext } from '../../hooks/useDark';
|
||||
import useLayoutState from '../../hooks/useLayoutState';
|
||||
import type { ThemeName } from '../common/ThemeSwitch';
|
||||
import SiteThemeProvider from '../SiteThemeProvider';
|
||||
import type { SiteContextProps } from '../slots/SiteContext';
|
||||
import type { SimpleComponentClassNames, SiteContextProps } from '../slots/SiteContext';
|
||||
import SiteContext from '../slots/SiteContext';
|
||||
|
||||
import '@ant-design/v5-patch-for-react-19';
|
||||
|
||||
type Entries<T> = { [K in keyof T]: [K, T[K]] }[keyof T][];
|
||||
type SiteState = Partial<Omit<SiteContextProps, 'updateSiteConfig'>>;
|
||||
type SiteState = Partial<Omit<SiteContextProps, 'updateSiteContext'>>;
|
||||
|
||||
const RESPONSIVE_MOBILE = 768;
|
||||
export const ANT_DESIGN_NOT_SHOW_BANNER = 'ANT_DESIGN_NOT_SHOW_BANNER';
|
||||
@@ -72,12 +71,13 @@ const getAlgorithm = (themes: ThemeName[] = []) =>
|
||||
const GlobalLayout: React.FC = () => {
|
||||
const outlet = useOutlet();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [{ theme = [], direction, isMobile, bannerVisible = false }, setSiteState] =
|
||||
const [{ theme = [], direction, isMobile, bannerVisible = false, dynamicTheme }, setSiteState] =
|
||||
useLayoutState<SiteState>({
|
||||
isMobile: false,
|
||||
direction: 'ltr',
|
||||
theme: [],
|
||||
bannerVisible: false,
|
||||
dynamicTheme: undefined,
|
||||
});
|
||||
|
||||
const [systemTheme, setSystemTheme] = React.useState<'dark' | 'light'>(() => getSystemTheme());
|
||||
@@ -90,7 +90,9 @@ const GlobalLayout: React.FC = () => {
|
||||
const oldSearchStr = searchParams.toString();
|
||||
|
||||
let nextSearchParams: URLSearchParams = searchParams;
|
||||
(Object.entries(props) as Entries<SiteContextProps>).forEach(([key, value]) => {
|
||||
Object.entries(props).forEach((kv) => {
|
||||
const [key, value] = kv as [string, string];
|
||||
|
||||
if (key === 'direction') {
|
||||
if (value === 'rtl') {
|
||||
nextSearchParams.set('direction', 'rtl');
|
||||
@@ -192,18 +194,47 @@ const GlobalLayout: React.FC = () => {
|
||||
theme: theme!,
|
||||
isMobile: isMobile!,
|
||||
bannerVisible,
|
||||
dynamicTheme,
|
||||
}),
|
||||
[isMobile, direction, updateSiteConfig, theme, bannerVisible],
|
||||
[isMobile, direction, updateSiteConfig, theme, bannerVisible, dynamicTheme],
|
||||
);
|
||||
|
||||
const themeConfig = React.useMemo<ThemeConfig>(
|
||||
() => ({
|
||||
algorithm: getAlgorithm(theme),
|
||||
token: { motion: !theme.includes('motion-off') },
|
||||
hashed: false,
|
||||
}),
|
||||
[theme],
|
||||
);
|
||||
const [themeConfig, componentsClassNames] = React.useMemo<
|
||||
[ThemeConfig, SimpleComponentClassNames]
|
||||
>(() => {
|
||||
let mergedTheme = theme;
|
||||
|
||||
const {
|
||||
algorithm: dynamicAlgorithm,
|
||||
token: dynamicToken,
|
||||
...rawComponentsClassNames
|
||||
} = dynamicTheme || {};
|
||||
|
||||
if (dynamicAlgorithm) {
|
||||
mergedTheme = mergedTheme.filter((c) => c !== 'dark' && c !== 'light');
|
||||
mergedTheme.push(dynamicAlgorithm);
|
||||
}
|
||||
|
||||
// Convert rawComponentsClassNames to nextComponentsClassNames
|
||||
const nextComponentsClassNames: any = {};
|
||||
Object.keys(rawComponentsClassNames).forEach((key) => {
|
||||
nextComponentsClassNames[key] = {
|
||||
classNames: (rawComponentsClassNames as any)[key],
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
algorithm: getAlgorithm(mergedTheme),
|
||||
token: {
|
||||
motion: !theme.includes('motion-off'),
|
||||
...dynamicToken,
|
||||
},
|
||||
hashed: false,
|
||||
},
|
||||
nextComponentsClassNames,
|
||||
];
|
||||
}, [theme, dynamicTheme]);
|
||||
|
||||
const [styleCache] = React.useState(() => createCache());
|
||||
|
||||
@@ -245,12 +276,15 @@ const GlobalLayout: React.FC = () => {
|
||||
<DarkContext value={theme.includes('dark')}>
|
||||
<StyleProvider
|
||||
cache={styleCache}
|
||||
layer
|
||||
linters={[legacyNotSelectorLinter, parentSelectorLinter, NaNLinter]}
|
||||
>
|
||||
<SiteContext value={siteContextValue}>
|
||||
<SiteThemeProvider theme={themeConfig}>
|
||||
<HappyProvider disabled={!theme.includes('happy-work')}>
|
||||
<App>{outlet}</App>
|
||||
<ConfigProvider {...componentsClassNames}>
|
||||
<App>{outlet}</App>
|
||||
</ConfigProvider>
|
||||
</HappyProvider>
|
||||
</SiteThemeProvider>
|
||||
</SiteContext>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"app.theme.switch.motion.on": "Motion On",
|
||||
"app.theme.switch.motion.off": "Motion Off",
|
||||
"app.theme.switch.happy-work": "Happy Work Effect",
|
||||
"app.theme.switch.market": "AI Theme Generator",
|
||||
"app.header.search": "Search...",
|
||||
"app.header.menu.documentation": "Docs",
|
||||
"app.header.menu.more": "More",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"app.theme.switch.motion.on": "动画开启",
|
||||
"app.theme.switch.motion.off": "动画关闭",
|
||||
"app.theme.switch.happy-work": "快乐工作特效",
|
||||
"app.theme.switch.market": "AI 生成主题",
|
||||
"app.header.search": "全文本搜索...",
|
||||
"app.header.menu.documentation": "文档",
|
||||
"app.header.menu.more": "更多",
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import * as React from 'react';
|
||||
import type { DirectionType } from 'antd/es/config-provider';
|
||||
|
||||
import type { ConfigComponentProps } from '../../../components/config-provider/context';
|
||||
import type { ThemeName } from '../common/ThemeSwitch';
|
||||
|
||||
export type SimpleComponentClassNames = Partial<
|
||||
Record<keyof ConfigComponentProps, Record<string, string>>
|
||||
>;
|
||||
|
||||
export interface SiteContextProps {
|
||||
isMobile: boolean;
|
||||
bannerVisible: boolean;
|
||||
direction: DirectionType;
|
||||
theme: ThemeName[];
|
||||
updateSiteConfig: (props: Partial<SiteContextProps>) => void;
|
||||
|
||||
dynamicTheme?: {
|
||||
algorithm?: 'light' | 'dark';
|
||||
token: Record<string, string | number>;
|
||||
} & SimpleComponentClassNames;
|
||||
}
|
||||
|
||||
const SiteContext = React.createContext<SiteContextProps>({
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import os from 'node:os';
|
||||
import path from 'path';
|
||||
import { defineConfig } from 'dumi';
|
||||
import * as fs from 'fs-extra';
|
||||
import os from 'node:os';
|
||||
|
||||
import rehypeAntd from './.dumi/rehypeAntd';
|
||||
import rehypeChangelog from './.dumi/rehypeChangelog';
|
||||
import remarkAntd from './.dumi/remarkAntd';
|
||||
import remarkAnchor from './.dumi/remarkAnchor';
|
||||
import remarkAntd from './.dumi/remarkAntd';
|
||||
import { version } from './package.json';
|
||||
|
||||
export default defineConfig({
|
||||
@@ -187,6 +187,10 @@ export default defineConfig({
|
||||
`,
|
||||
],
|
||||
scripts: [
|
||||
{
|
||||
src: 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4',
|
||||
async: true,
|
||||
},
|
||||
{
|
||||
async: true,
|
||||
content: fs
|
||||
|
||||
@@ -12,11 +12,11 @@ const locales = {
|
||||
input: '输入框元素',
|
||||
suffix: '后缀元素',
|
||||
popup: '弹出框元素',
|
||||
popupHeader: '弹出框头部元素',
|
||||
popupBody: '弹出框主体元素',
|
||||
popupContent: '弹出框内容元素',
|
||||
popupItem: '弹出框单项元素',
|
||||
popupFooter: '弹出框底部元素',
|
||||
'popup.header': '弹出框头部元素',
|
||||
'popup.body': '弹出框主体元素',
|
||||
'popup.content': '弹出框内容元素',
|
||||
'popup.item': '弹出框单项元素',
|
||||
'popup.footer': '弹出框底部元素',
|
||||
},
|
||||
en: {
|
||||
root: 'Root element',
|
||||
@@ -24,11 +24,11 @@ const locales = {
|
||||
input: 'Input element',
|
||||
suffix: 'Suffix element',
|
||||
popup: 'Popup element',
|
||||
popupHeader: 'Popup header element',
|
||||
popupBody: 'Popup body element',
|
||||
popupContent: 'Popup content element',
|
||||
popupItem: 'Popup content item element',
|
||||
popupFooter: 'Popup footer element',
|
||||
'popup.header': 'Popup header element',
|
||||
'popup.body': 'Popup body element',
|
||||
'popup.content': 'Popup content element',
|
||||
'popup.item': 'Popup content item element',
|
||||
'popup.footer': 'Popup footer element',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -97,11 +97,11 @@ export function PickerSemanticTemplate(props: PickerSemanticTemplateProps) {
|
||||
{ name: 'input', desc: locale.input },
|
||||
{ name: 'suffix', desc: locale.suffix },
|
||||
{ name: 'popup.root', desc: locale.popup },
|
||||
{ name: 'popup.header', desc: locale.popupHeader },
|
||||
{ name: 'popup.body', desc: locale.popupBody },
|
||||
{ name: 'popup.content', desc: locale.popupContent },
|
||||
{ name: 'popup.item', desc: locale.popupItem },
|
||||
{ name: 'popup.footer', desc: locale.popupFooter },
|
||||
{ name: 'popup.header', desc: locale['popup.header'] },
|
||||
{ name: 'popup.body', desc: locale['popup.body'] },
|
||||
{ name: 'popup.content', desc: locale['popup.content'] },
|
||||
{ name: 'popup.item', desc: locale['popup.item'] },
|
||||
{ name: 'popup.footer', desc: locale['popup.footer'] },
|
||||
].filter((semantic) => !ignoreSemantics.includes(semantic.name))}
|
||||
>
|
||||
<Block
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"strategy": "auto"
|
||||
},
|
||||
"watch": {
|
||||
"_nodeModulesRegexes": ["rc-.*"]
|
||||
"_nodeModulesRegexes": ["rc-.*", ".*cssinjs.*"]
|
||||
},
|
||||
"devtool": false,
|
||||
"experimental": {
|
||||
|
||||
@@ -66,7 +66,8 @@
|
||||
"lint:script": "eslint . --cache",
|
||||
"lint:biome": "biome lint",
|
||||
"lint:style": "tsx scripts/check-cssinjs.tsx",
|
||||
"llms": "tsx scripts/generate-llms.ts",
|
||||
"llms": "npm run llms-semantic && tsx scripts/generate-llms.ts",
|
||||
"llms-semantic": "tsx scripts/generate-llms-semantic.ts",
|
||||
"prepare": "is-ci || husky && dumi setup",
|
||||
"prepublishOnly": "tsx ./scripts/pre-publish.ts",
|
||||
"prettier": "prettier -c --write . --cache",
|
||||
@@ -161,6 +162,7 @@
|
||||
"@ant-design/happy-work-theme": "^1.0.0",
|
||||
"@ant-design/tools": "^19.0.3",
|
||||
"@ant-design/v5-patch-for-react-19": "^1.0.2",
|
||||
"@ant-design/x": "^1.4.0",
|
||||
"@antfu/eslint-config": "^4.15.0",
|
||||
"@antv/g6": "^4.8.24",
|
||||
"@biomejs/biome": "^2.0.4",
|
||||
@@ -331,6 +333,7 @@
|
||||
"webpack": "^5.100.0",
|
||||
"webpack-bundle-analyzer": "^4.10.2",
|
||||
"xhr-mock": "^2.5.1"
|
||||
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
@@ -362,4 +365,4 @@
|
||||
"resolutions": {
|
||||
"nwsapi": "2.2.20"
|
||||
}
|
||||
}
|
||||
}
|
||||
225
scripts/generate-llms-semantic.ts
Normal file
225
scripts/generate-llms-semantic.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
// Note: Generated By Copilot
|
||||
|
||||
import path from 'path';
|
||||
import fs from 'fs-extra';
|
||||
import { glob } from 'glob';
|
||||
|
||||
// 特殊组件名转换映射
|
||||
const ConvertMap: Record<string, string> = {
|
||||
'badge:ribbon': 'ribbon',
|
||||
'floatButton:group': 'floatButtonGroup',
|
||||
'input:input': 'input',
|
||||
'input:otp': 'otp',
|
||||
'input:search': 'inputSearch',
|
||||
'input:textarea': 'textArea',
|
||||
};
|
||||
|
||||
// 将 kebab-case 转换为 camelCase
|
||||
function toCamelCase(str: string): string {
|
||||
return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
// 构建嵌套结构的辅助函数
|
||||
function buildNestedStructure(flatSemantics: Record<string, string>): any {
|
||||
const result: any = {};
|
||||
|
||||
// 首先,收集所有带点号的键,确定哪些是父级
|
||||
const nestedKeys = new Set<string>();
|
||||
for (const key of Object.keys(flatSemantics)) {
|
||||
if (key.includes('.')) {
|
||||
const parentKey = key.split('.')[0];
|
||||
nestedKeys.add(parentKey);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(flatSemantics)) {
|
||||
if (key.includes('.')) {
|
||||
const parts = key.split('.');
|
||||
let current = result;
|
||||
|
||||
// 遍历除最后一个部分外的所有部分
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i];
|
||||
if (!current[part]) {
|
||||
current[part] = {};
|
||||
} else if (typeof current[part] === 'string') {
|
||||
// 如果已经存在一个字符串值,需要将其转换为对象
|
||||
// 保留原有的值作为一个特殊的 _value 属性或者忽略冲突
|
||||
// console.warn(
|
||||
// `Warning: Key conflict for '${part}', nested structure will override simple value`,
|
||||
// );
|
||||
current[part] = {};
|
||||
}
|
||||
current = current[part];
|
||||
}
|
||||
|
||||
// 设置最后一个部分的值
|
||||
current[parts[parts.length - 1]] = value;
|
||||
} else {
|
||||
// 只有当这个键不是某个嵌套结构的父级时,才直接设置值
|
||||
if (!nestedKeys.has(key)) {
|
||||
result[key] = value;
|
||||
} else {
|
||||
// 如果这个键将成为父级,先确保它是一个对象
|
||||
if (!result[key]) {
|
||||
result[key] = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 递归生成 markdown 格式的嵌套结构
|
||||
function generateMarkdownStructure(obj: any, prefix = '', indent = 0): string {
|
||||
let result = '';
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const indentStr = ' '.repeat(indent);
|
||||
if (typeof value === 'string') {
|
||||
result += `${indentStr}- \`${key}\`: ${value}\n`;
|
||||
} else {
|
||||
result += `${indentStr}- \`${key}\`:\n`;
|
||||
result += generateMarkdownStructure(value, prefix, indent + 1);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 递归打印嵌套结构的辅助函数(已注释掉)
|
||||
// function printNestedStructure(obj: any, prefix = '', indent = 0): void {
|
||||
// for (const [key, value] of Object.entries(obj)) {
|
||||
// const indentStr = ' '.repeat(indent);
|
||||
// if (typeof value === 'string') {
|
||||
// console.log(`${indentStr}- ${key}: ${value}`);
|
||||
// } else {
|
||||
// console.log(`${indentStr}- ${key}:`);
|
||||
// printNestedStructure(value, prefix, indent + 1);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
async function generateSemanticDesc() {
|
||||
const cwd = process.cwd();
|
||||
const siteDir = path.resolve(cwd, '_site');
|
||||
const docsDir = ['components', 'docs'];
|
||||
|
||||
// Ensure siteDir
|
||||
await fs.ensureDir(siteDir);
|
||||
|
||||
const docs = await glob(`{${docsDir.join(',')}}/**/demo/_semantic*.tsx`);
|
||||
|
||||
// Read `docs` file and generate semantic description.e.g.
|
||||
// components/float-button/demo/_semantic_group.tsx is to:
|
||||
// - root: 根元素
|
||||
// - list: 列表元素
|
||||
// - item: 列表项元素
|
||||
// - root: 列表项根元素
|
||||
// - icon: 列表项图标元素
|
||||
// - content: 列表项内容元素
|
||||
// - trigger:
|
||||
// - root: 触发元素
|
||||
// - icon: 触发图标元素
|
||||
// - content: 触发内容元素
|
||||
|
||||
const semanticDescriptions: Record<string, any> = {};
|
||||
|
||||
for (const docPath of docs) {
|
||||
try {
|
||||
// 读取文件内容
|
||||
const content = await fs.readFile(docPath, 'utf-8');
|
||||
|
||||
// 提取组件名称(从路径中获取)
|
||||
const componentName = path.basename(path.dirname(path.dirname(docPath)));
|
||||
const fileName = path.basename(docPath, '.tsx');
|
||||
|
||||
// 转换为 camelCase
|
||||
const camelCaseComponentName = toCamelCase(componentName);
|
||||
|
||||
// 如果是 _semantic.tsx,使用组件名;如果是其他变体,添加后缀
|
||||
let semanticKey = camelCaseComponentName;
|
||||
if (fileName !== '_semantic') {
|
||||
const variant = fileName.replace('_semantic_', '').replace('_semantic', '');
|
||||
if (variant) {
|
||||
semanticKey = `${camelCaseComponentName}:${variant}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有特殊转换映射
|
||||
if (ConvertMap[semanticKey]) {
|
||||
semanticKey = ConvertMap[semanticKey];
|
||||
}
|
||||
|
||||
// 使用正则表达式提取 locales 对象
|
||||
const localesMatch = content.match(/const locales = \{([\s\S]*?)\};/);
|
||||
if (!localesMatch) {
|
||||
// console.warn(`No locales found in ${docPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 提取中文 locales
|
||||
const cnMatch = content.match(/cn:\s*\{([\s\S]*?)\},?\s*en:/);
|
||||
if (!cnMatch) {
|
||||
// console.warn(`No Chinese locales found in ${docPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const cnContent = cnMatch[1];
|
||||
const flatSemantics: Record<string, string> = {};
|
||||
|
||||
// 提取每个语义描述
|
||||
const semanticMatches = cnContent.matchAll(/['"]?([^'":\s]+)['"]?\s*:\s*['"]([^'"]+)['"],?/g);
|
||||
for (const match of semanticMatches) {
|
||||
const [, key, value] = match;
|
||||
flatSemantics[key] = value;
|
||||
}
|
||||
|
||||
if (Object.keys(flatSemantics).length > 0) {
|
||||
// 将扁平的语义描述转换为层级结构
|
||||
const nestedSemantics = buildNestedStructure(flatSemantics);
|
||||
semanticDescriptions[semanticKey] = nestedSemantics;
|
||||
|
||||
// 生成层级结构的描述(已注释掉)
|
||||
// console.log(`\n${semanticKey}:`);
|
||||
// printNestedStructure(nestedSemantics);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing ${docPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 生成 markdown 内容
|
||||
let markdownContent = '# Ant Design 组件语义化描述\n\n';
|
||||
markdownContent += '本文档包含了 Ant Design 组件库中所有组件的语义化描述信息。\n\n';
|
||||
markdownContent += `> 总计 ${Object.keys(semanticDescriptions).length} 个组件包含语义化描述\n\n`;
|
||||
markdownContent += '## 组件列表\n\n';
|
||||
|
||||
// 按组件名排序
|
||||
const sortedComponents = Object.keys(semanticDescriptions).sort();
|
||||
|
||||
for (const componentName of sortedComponents) {
|
||||
const semantics = semanticDescriptions[componentName];
|
||||
markdownContent += `### ${componentName}\n\n`;
|
||||
markdownContent += generateMarkdownStructure(semantics);
|
||||
markdownContent += '\n';
|
||||
}
|
||||
|
||||
// 生成总结(已注释掉)
|
||||
// console.log('\n=== Semantic Descriptions Summary ===');
|
||||
// console.log(
|
||||
// `Total components with semantic descriptions: ${Object.keys(semanticDescriptions).length}`,
|
||||
// );
|
||||
|
||||
// 将结果写入 markdown 文件
|
||||
const outputPath = path.join(siteDir, 'llms-semantic.md');
|
||||
await fs.writeFile(outputPath, markdownContent, 'utf-8');
|
||||
console.log(`Semantic descriptions saved to: ${outputPath}`);
|
||||
}
|
||||
(async () => {
|
||||
if (require.main === module) {
|
||||
await generateSemanticDesc();
|
||||
}
|
||||
})().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -65,13 +65,26 @@ async function generateLLms() {
|
||||
'',
|
||||
'- Ant Design, developed by Ant Group, is a React UI library that aims to provide a high-quality design language and development framework for enterprise-level backend management systems. It offers a rich set of components and design guidelines, helping developers build modern, responsive, and high-performance web applications.',
|
||||
'',
|
||||
'## Semantic Descriptions',
|
||||
'',
|
||||
'- [Ant Design Component Semantic Descriptions](https://ant.design/llms-semantic.md)',
|
||||
'',
|
||||
'## Docs',
|
||||
'',
|
||||
...docsIndex.map(({ title, url }) => `- [${title}](${url})`),
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const docsBodyContent = docsBody.join('\n');
|
||||
const docsBodyContent = [
|
||||
'---',
|
||||
'Title: Ant Design Component Semantic Descriptions',
|
||||
'URL: https://ant.design/llms-semantic.md',
|
||||
'---',
|
||||
'',
|
||||
(await fs.readFile(path.join(siteDir, 'llms-semantic.md'), 'utf-8')).trim(),
|
||||
'',
|
||||
...docsBody,
|
||||
].join('\n');
|
||||
|
||||
await fs.writeFile(path.join(siteDir, 'llms.txt'), docsIndexContent);
|
||||
await fs.writeFile(path.join(siteDir, 'llms-full.txt'), docsBodyContent);
|
||||
|
||||
Reference in New Issue
Block a user