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:
二货爱吃白萝卜
2025-07-18 16:19:51 +08:00
committed by GitHub
parent c04f326fed
commit f842163a70
16 changed files with 644 additions and 52 deletions

View File

@@ -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>
);
};

View 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;

View File

@@ -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,
});
}}
/>
</>
);
};

View 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;
}

View File

@@ -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,
});
}, []);

View File

@@ -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>;

View File

@@ -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>

View File

@@ -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",

View File

@@ -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": "更多",

View File

@@ -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>({

View File

@@ -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

View File

@@ -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

View File

@@ -7,7 +7,7 @@
"strategy": "auto"
},
"watch": {
"_nodeModulesRegexes": ["rc-.*"]
"_nodeModulesRegexes": ["rc-.*", ".*cssinjs.*"]
},
"devtool": false,
"experimental": {

View File

@@ -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"
}
}
}

View 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);
});

View File

@@ -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);