diff --git a/.eslintrc.js b/.eslintrc.js index 2898340945..fb47074c10 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,3 +1,9 @@ module.exports = { extends: require.resolve('@umijs/lint/dist/config/eslint'), + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { ignoreRestSiblings: true }, + ], + }, }; diff --git a/index.d.ts b/index.d.ts index e5b87bed0e..728d8df009 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,3 +1,4 @@ +export * from '@@/dumi/exports'; export { Root as HastRoot } from 'hast'; export * from 'umi'; export { @@ -7,8 +8,4 @@ export { export * from './dist'; // override umi exported defineConfig export { defineConfig } from './dist'; -export * from './dist/client/theme-api'; export { IApi } from './dist/types'; -export function getRouteMetaById(id: string): Promise; -/** @private Internal usage. Safe to remove */ -export function loadFilesMeta(): Promise; diff --git a/src/client/pages/Demo/index.ts b/src/client/pages/Demo/index.ts index 7e11c6ccbb..0cb49b7360 100644 --- a/src/client/pages/Demo/index.ts +++ b/src/client/pages/Demo/index.ts @@ -1,13 +1,10 @@ -import { useDemoData, useParams } from 'dumi'; +import { useDemo, useParams } from 'dumi'; import { createElement, type FC } from 'react'; import './index.less'; const DemoRenderPage: FC = () => { const { id } = useParams(); - - const demoInfo = useDemoData(id!); - - const { component } = demoInfo!; + const { component } = useDemo(id!) || {}; return component && createElement(component); }; diff --git a/src/client/theme-api/DumiDemo/index.tsx b/src/client/theme-api/DumiDemo/index.tsx index 70ee8ff197..d2bb348537 100644 --- a/src/client/theme-api/DumiDemo/index.tsx +++ b/src/client/theme-api/DumiDemo/index.tsx @@ -1,5 +1,5 @@ import { SP_ROUTE_PREFIX } from '@/constants'; -import { useAppData, useDemoData, useSiteData } from 'dumi'; +import { useAppData, useDemo, useSiteData } from 'dumi'; import React, { createElement, type FC } from 'react'; import type { IPreviewerProps } from '../types'; @@ -17,14 +17,12 @@ export interface IDumiDemoProps { const InternalDumiDemo = (props: IDumiDemoProps) => { const { historyType } = useSiteData(); const { basename } = useAppData(); - const demoInfo = useDemoData(props.demo.id)!; + const { component, asset } = useDemo(props.demo.id)!; // hide debug demo in production if (process.env.NODE_ENV === 'production' && props.previewerProps.debug) return null; - const { component, asset } = demoInfo; - const demoNode = ( {createElement(component)} ); diff --git a/src/client/theme-api/context.ts b/src/client/theme-api/context.ts new file mode 100644 index 0000000000..176db2e477 --- /dev/null +++ b/src/client/theme-api/context.ts @@ -0,0 +1,38 @@ +import type { PICKED_PKG_FIELDS } from '@/constants'; +import type { AtomComponentAsset } from 'dumi-assets-types'; +import { createContext, useContext } from 'react'; +import type { IDemoData, ILocalesConfig, IThemeConfig } from './types'; + +interface ISiteContext { + pkg: Partial>; + historyType: 'browser' | 'hash' | 'memory'; + entryExports: Record; + demos: Record; + components: Record; + locales: ILocalesConfig; + themeConfig: IThemeConfig; + hostname?: string; + loading: boolean; + setLoading: (status: boolean) => void; + /** + * private field, do not use it in your code + */ + _2_level_nav_available: boolean; +} + +export const SiteContext = createContext({ + pkg: {}, + historyType: 'browser', + entryExports: {}, + demos: {}, + components: {}, + locales: [], + themeConfig: {} as IThemeConfig, + loading: false, + setLoading: () => {}, + _2_level_nav_available: true, +}); + +export const useSiteData = () => { + return useContext(SiteContext); +}; diff --git a/src/client/theme-api/context/index.ts b/src/client/theme-api/context/index.ts deleted file mode 100644 index 6b9db11478..0000000000 --- a/src/client/theme-api/context/index.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { PICKED_PKG_FIELDS } from '@/constants'; -import type { AtomComponentAsset } from 'dumi-assets-types'; -import { createContext, useContext, type ComponentType } from 'react'; -import type { - ILocalesConfig, - IPreviewerProps, - IRouteMeta, - IThemeConfig, -} from '../types'; -import use from './use'; - -export type DemoInfo = { - component: ComponentType; - asset: IPreviewerProps['asset']; - routeId: string; -}; - -interface ISiteContext { - pkg: Partial>; - historyType: 'browser' | 'hash' | 'memory'; - entryExports: Record; - demos: Record; - components: Record; - tabs: IRouteMeta['tabs']; - locales: ILocalesConfig; - themeConfig: IThemeConfig; - hostname?: string; - loading: boolean; - setLoading: (status: boolean) => void; - /** - * private field, do not use it in your code - */ - _2_level_nav_available: boolean; - - // =================== Demo Map =================== - // Demo map is root providing the id => route mapping info. - // Which is generated by `meta-demos` template - getDemoById: (id: string) => Promise; -} - -export const SiteContext = createContext({ - pkg: {}, - historyType: 'browser', - entryExports: {}, - demos: {}, - components: {}, - locales: [], - themeConfig: {} as IThemeConfig, - loading: false, - setLoading: () => {}, - _2_level_nav_available: true, - getDemoById: async () => null, - tabs: [], -}); - -export const useSiteData = () => { - return useContext(SiteContext); -}; - -const cache = new Map>(); - -// Async load demo data -export function useDemoData(demoId: string) { - const { getDemoById } = useSiteData(); - - if (!cache.has(demoId)) { - cache.set(demoId, getDemoById(demoId)); - } - - return use(cache.get(demoId)!); -} diff --git a/src/client/theme-api/context/use.ts b/src/client/theme-api/context/use.ts deleted file mode 100644 index f202de964f..0000000000 --- a/src/client/theme-api/context/use.ts +++ /dev/null @@ -1,33 +0,0 @@ -// Copy from React official demo. -// This will be replace if React release new version of use hooks -type ReactPromise = Promise & { - status?: 'pending' | 'fulfilled' | 'rejected'; - value?: T; - reason?: any; -}; - -/** - * @private Internal usage. Safe to remove - */ -export default function use(promise: ReactPromise): T { - if (promise.status === 'fulfilled') { - return promise.value!; - } else if (promise.status === 'rejected') { - throw promise.reason; - } else if (promise.status === 'pending') { - throw promise; - } else { - promise.status = 'pending'; - promise.then( - (result) => { - promise.status = 'fulfilled'; - promise.value = result; - }, - (reason) => { - promise.status = 'rejected'; - promise.reason = reason; - }, - ); - throw promise; - } -} diff --git a/src/client/theme-api/index.ts b/src/client/theme-api/index.ts index 1abe878d52..ebb2be0a42 100644 --- a/src/client/theme-api/index.ts +++ b/src/client/theme-api/index.ts @@ -22,7 +22,7 @@ export { AtomRenderer } from './AtomRenderer'; export { DumiDemo } from './DumiDemo'; export { DumiDemoGrid } from './DumiDemoGrid'; export { DumiPage } from './DumiPage'; -export { useDemoData, useSiteData } from './context'; +export { useSiteData } from './context'; export { evalCode } from './evalCode'; export { LiveContext, LiveProvider, isLiveEnabled } from './live'; export { openCodeSandbox } from './openCodeSandbox'; diff --git a/src/client/theme-api/live/useDemoScopes.ts b/src/client/theme-api/live/useDemoScopes.ts index a6aa56f97d..a8048b4bbd 100644 --- a/src/client/theme-api/live/useDemoScopes.ts +++ b/src/client/theme-api/live/useDemoScopes.ts @@ -1,5 +1,5 @@ import { getDemoScopesById } from 'dumi'; -import use from '../context/use'; +import { use } from '../utils'; const cache = new Map(); diff --git a/src/client/theme-api/types.ts b/src/client/theme-api/types.ts index 6ea5903298..c4aed3510f 100644 --- a/src/client/theme-api/types.ts +++ b/src/client/theme-api/types.ts @@ -240,3 +240,9 @@ export type IRoutesById = Record< [key: string]: any; } >; + +export type IDemoData = { + component: ComponentType; + asset: IPreviewerProps['asset']; + routeId: string; +}; diff --git a/src/client/theme-api/useRouteMeta.ts b/src/client/theme-api/useRouteMeta.ts index d4da234281..b24dbf907b 100644 --- a/src/client/theme-api/useRouteMeta.ts +++ b/src/client/theme-api/useRouteMeta.ts @@ -5,25 +5,71 @@ import { useLocation, useRouteData, } from 'dumi'; -import { useMemo } from 'react'; -import use from './context/use'; -import type { IRouteMeta } from './types'; +import { useCallback, useState } from 'react'; +import type { IRouteMeta, IRoutesById } from './types'; +import { use, useIsomorphicLayoutEffect } from './utils'; -const cache = new Map(); - -const useAsyncRouteMeta = (id: string) => { - if (!cache.has(id)) { - cache.set(id, getRouteMetaById(id)); - } - - return use(cache.get(id)!); -}; - -const emptyMeta = { +const cache = new Map>(); +const EMPTY_META = { frontmatter: {}, toc: [], texts: [], -}; +} as any; +const ASYNC_META_PROPS = ['texts']; + +function getCachedRouteMeta(route: IRoutesById[string]) { + const cacheKey = route.id; + const pendingCacheKey = `${cacheKey}:pending`; + + if (!cache.get(cacheKey)) { + const merge = (meta: IRouteMeta = EMPTY_META) => { + if (route.meta) { + Object.keys(route.meta).forEach((key) => { + (meta as any)[key] ??= (route.meta as any)[key]; + }); + } + + return meta; + }; + const meta = merge(getRouteMetaById(route.id, { syncOnly: true })); + const ret: Parameters>[0] = Promise.resolve(meta); + const proxyGetter = (target: any, prop: string) => { + if (ASYNC_META_PROPS.includes(prop)) { + if (!cache.get(pendingCacheKey)) { + // load async meta then replace cache + cache.set( + pendingCacheKey, + getRouteMetaById(route.id).then((full) => + cache.set(cacheKey, Promise.resolve(merge(full))).get(cacheKey), + ), + ); + } + + // throw promise to trigger suspense + throw cache.get(pendingCacheKey); + } + + return target[prop]; + }; + + // return sync meta by default + ret.status = 'fulfilled'; + + // load async meta if property accessed + meta.tabs?.forEach((tab) => { + tab.meta = new Proxy(tab.meta, { + get: proxyGetter, + }); + }); + ret.value = new Proxy(meta, { + get: proxyGetter, + }); + + cache.set(cacheKey, ret); + } + + return cache.get(cacheKey)!; +} /** * hook for get matched route meta @@ -32,26 +78,26 @@ export const useRouteMeta = () => { const { route } = useRouteData(); const { pathname } = useLocation(); const { clientRoutes } = useAppData(); + const getter = useCallback(() => { + let ret: IRoutesById[string]; - const curRoute = useMemo(() => { if (route.path === pathname && !('isLayout' in route)) { // use `useRouteData` result if matched, for performance - return route; + ret = route as any; } else { // match manually for dynamic route & layout component const matched = matchRoutes(clientRoutes, pathname)?.pop(); - return matched?.route; + ret = matched?.route as any; } - }, [clientRoutes.length, pathname]); - const meta: IRouteMeta = - useAsyncRouteMeta((curRoute as any)?.id) || emptyMeta; + return ret; + }, [clientRoutes.length, pathname]); + const [matchedRoute, setMatchedRoute] = useState(getter); + const meta = use(getCachedRouteMeta(matchedRoute)); - if (curRoute && 'meta' in curRoute && typeof curRoute.meta === 'object') { - Object.keys(curRoute.meta as IRouteMeta).forEach((key) => { - (meta as any)[key] ??= (curRoute as any).meta[key]; - }); - } + useIsomorphicLayoutEffect(() => { + setMatchedRoute(getter); + }, [clientRoutes.length, pathname]); - return meta; + return meta!; }; diff --git a/src/client/theme-api/useSiteSearch/index.ts b/src/client/theme-api/useSiteSearch/index.ts index 11d8fb6570..297b485d2e 100644 --- a/src/client/theme-api/useSiteSearch/index.ts +++ b/src/client/theme-api/useSiteSearch/index.ts @@ -38,20 +38,18 @@ if (typeof window !== 'undefined') { export const useSiteSearch = () => { const debounceTimer = useRef(); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(false); const [keywords, setKeywords] = useState(''); - const [enabled, setEnabled] = useState(false); const navData = useNavData(); const [result, setResult] = useState([]); + const [data, load] = useSearchData(); const setter = useCallback((val: string) => { + load(); setLoading(true); setKeywords(val); }, []); - - const loadSearchData = () => setEnabled(true); - - const [filledRoutes, demos] = useSearchData(enabled); - const mergedLoading = loading || !filledRoutes; + const routes = data?.[0]; + const demos = data?.[1]; useEffect(() => { worker.onmessage = (e) => { @@ -61,35 +59,20 @@ export const useSiteSearch = () => { }, []); useEffect(() => { - if (!filledRoutes || !demos) { - return; - } - - // omit demo component for postmessage - const demoData = Object.entries(demos).reduce< - Record> - >( - (acc, [key, { asset, routeId }]) => ({ - ...acc, - [key]: { asset, routeId }, - }), - {}, - ); + if (!routes || !demos) return; worker.postMessage({ action: 'generate-metadata', args: { - routes: JSON.parse(JSON.stringify(filledRoutes)), + routes: JSON.parse(JSON.stringify(routes)), nav: navData, - demos: demoData, + demos: demos, }, }); - }, [demos, navData, filledRoutes]); + }, [routes, demos, navData]); useEffect(() => { - if (!filledRoutes) { - return; - } + if (!routes) return; const str = keywords.trim(); @@ -106,13 +89,13 @@ export const useSiteSearch = () => { } else { setResult([]); } - }, [keywords, filledRoutes]); + }, [keywords, routes]); return { keywords, setKeywords: setter, result, - loading: mergedLoading, - loadSearchData, + loading, + load, }; }; diff --git a/src/client/theme-api/useSiteSearch/useSearchData.ts b/src/client/theme-api/useSiteSearch/useSearchData.ts index 72f048df6d..ebd17897a0 100644 --- a/src/client/theme-api/useSiteSearch/useSearchData.ts +++ b/src/client/theme-api/useSiteSearch/useSearchData.ts @@ -1,77 +1,47 @@ -import { loadFilesMeta, useSiteData } from 'dumi'; -import React from 'react'; -import type { DemoInfo } from '../context'; -import type { IRouteMeta } from '../types'; +import { getFullDemos, getFullRoutesMeta } from 'dumi'; +import { useCallback, useRef, useState } from 'react'; import { useLocaleDocRoutes } from '../utils'; -type RoutesData = Record; +type IDemosData = Record< + string, + Partial>[string]> +>; -type Demos = Record; - -type ReturnData = [ - routes: ReturnType | null, - demo: Demos | null, +type ISearchData = [ + routes: ReturnType, + demos: IDemosData, ]; -export default function useSearchData(enabled: boolean): ReturnData { +export default function useSearchData(): [ + ISearchData | null, + () => Promise, +] { const routes = useLocaleDocRoutes(); - const [filesMeta, setFilesMeta] = React.useState(null); - const { tabs } = useSiteData(); - - React.useEffect(() => { - if (enabled) { - loadFilesMeta(Object.keys(routes)).then((data) => { - setFilesMeta(data); - }); - } - }, [enabled, routes]); - - return React.useMemo(() => { - if (!filesMeta) { - return [null, null]; - } - - // Route Meta - const mergedRoutes: typeof routes = {}; - - Object.keys(routes).forEach((routeId) => { - mergedRoutes[routeId] = { - ...routes[routeId], - }; - - // Fill routes meta - if (mergedRoutes[routeId].meta) { - mergedRoutes[routeId].meta = { - ...mergedRoutes[routeId].meta, - ...filesMeta[routeId], - tabs: mergedRoutes[routeId].tabs?.map((id: string) => { - const meta = { - frontmatter: { title: tabs[id].title }, - toc: [], - texts: [], - }; - return { - ...tabs[id], - meta: filesMeta[id] || meta, - }; - }), + const [data, setData] = useState(null); + const loading = useRef(false); + const load = useCallback(async () => { + if (!loading.current && !data) { + const routesMeta = await getFullRoutesMeta(); + const demos: IDemosData = await getFullDemos(); + const mergedRoutes: typeof routes = {}; + + // generate new routes with meta data + Object.keys(routes).forEach((routeId) => { + mergedRoutes[routeId] = { + ...routes[routeId], + meta: routesMeta[routeId], }; - } - }); - - // Demos - const demos: Demos = Object.entries(filesMeta).reduce((acc, [id, meta]) => { - // append route id to demo - Object.values(meta.demos).forEach((demo) => { - demo.routeId = id; }); - // merge demos - Object.assign(acc, meta.demos); + // omit demo component for postmessage + Object.entries(demos).forEach(([id, { component, ...demo }]) => { + demos[id] = demo; + }); - return acc; - }, {}); + setData([mergedRoutes, demos]); + loading.current = false; + } + }, [data]); - return [mergedRoutes, demos]; - }, [filesMeta, routes, tabs]); + return [data, load]; } diff --git a/src/client/theme-api/useTabMeta.ts b/src/client/theme-api/useTabMeta.ts index ac18b3079a..fa721ed8f0 100644 --- a/src/client/theme-api/useTabMeta.ts +++ b/src/client/theme-api/useTabMeta.ts @@ -17,7 +17,7 @@ export const useTabQueryState = (): [string | null, (val?: string) => void] => { search: `?${params.toString()}`, }); }, - [params], + [params, pathname], ); return [params.get(TAB_QUERY_KEY), setTabQueryState]; diff --git a/src/client/theme-api/utils.ts b/src/client/theme-api/utils.ts index 76dec4dab9..43689d4ce0 100644 --- a/src/client/theme-api/utils.ts +++ b/src/client/theme-api/utils.ts @@ -146,3 +146,36 @@ export const pickRouteSortMeta = ( export function getLocaleNav(nav: IUserNavValue | INav, locale: ILocale) { return Array.isArray(nav) ? nav : nav[locale.id]; } + +// Copy from React official demo. +type ReactPromise = Promise & { + status?: 'pending' | 'fulfilled' | 'rejected'; + value?: T; + reason?: any; +}; + +/** + * @private Internal usage. Safe to remove + */ +export function use(promise: ReactPromise): T { + if (promise.status === 'fulfilled') { + return promise.value!; + } else if (promise.status === 'rejected') { + throw promise.reason; + } else if (promise.status === 'pending') { + throw promise; + } else { + promise.status = 'pending'; + promise.then( + (result) => { + promise.status = 'fulfilled'; + promise.value = result; + }, + (reason) => { + promise.status = 'rejected'; + promise.reason = reason; + }, + ); + throw promise; + } +} diff --git a/src/client/theme-default/slots/SearchBar/Mask.tsx b/src/client/theme-default/slots/SearchBar/Mask.tsx index 88ef46c55c..4f7a8f53b8 100644 --- a/src/client/theme-default/slots/SearchBar/Mask.tsx +++ b/src/client/theme-default/slots/SearchBar/Mask.tsx @@ -11,7 +11,7 @@ export const Mask: FC = (props) => { useEffect(() => { if (props.visible) { document.body.style.overflow = 'hidden'; - } else { + } else if (document.body.style.overflow) { document.body.style.overflow = ''; props.onClose?.(); } diff --git a/src/client/theme-default/slots/SearchBar/index.tsx b/src/client/theme-default/slots/SearchBar/index.tsx index 24340ad1f7..9b70fa1891 100644 --- a/src/client/theme-default/slots/SearchBar/index.tsx +++ b/src/client/theme-default/slots/SearchBar/index.tsx @@ -24,8 +24,13 @@ const SearchBar: FC = () => { const inputRef = useRef(null); const modalInputRef = useRef(null); const [symbol, setSymbol] = useState('⌘'); - const { keywords, setKeywords, result, loading, loadSearchData } = - useSiteSearch(); + const { + keywords, + setKeywords, + result, + loading, + load: loadSearchData, + } = useSiteSearch(); const [modalVisible, setModalVisible] = useState(false); useEffect(() => { diff --git a/src/client/theme-default/slots/SearchResult/index.tsx b/src/client/theme-default/slots/SearchResult/index.tsx index be15db45ba..543081f229 100644 --- a/src/client/theme-default/slots/SearchResult/index.tsx +++ b/src/client/theme-default/slots/SearchResult/index.tsx @@ -119,13 +119,6 @@ const SearchResult: FC<{ }> = (props) => { const [data, histsCount] = useFlatSearchData(props.data); const [activeIndex, setActiveIndex] = useState(-1); - const [loading, setLoading] = useState(props.loading); - - useEffect(() => { - if (!props.loading) { - setLoading(false); - } - }, [props.loading]); useEffect(() => { const handler = (ev: KeyboardEvent) => { @@ -156,7 +149,7 @@ const SearchResult: FC<{ let returnNode: React.ReactNode = null; - if (loading) { + if (props.loading) { returnNode = (
diff --git a/src/client/tsconfig.json b/src/client/tsconfig.json index 66f8d82e49..e0fe50a796 100644 --- a/src/client/tsconfig.json +++ b/src/client/tsconfig.json @@ -8,6 +8,7 @@ "@/*": ["src/*"], "@@/*": [".dumi/tmp/*"], "dumi": ["."], + "dumi/dist/*": ["src/*"], "dumi/theme/*": ["src/client/theme-default/*"] } } diff --git a/src/features/compile/index.ts b/src/features/compile/index.ts index 81921516cc..70b60de346 100644 --- a/src/features/compile/index.ts +++ b/src/features/compile/index.ts @@ -47,85 +47,45 @@ export default (api: IApi) => { pkg: api.pkg, }; - memo.module + const mdRule = memo.module .rule('dumi-md') .type('javascript/auto') - .test(/\.md$/) - // get page demo for each markdown file - .oneOf('md-demo') - .resourceQuery(/demo$/) - .use('babel-loader') - .loader(babelInUmi.loader) - .options(babelInUmi.options) - .end() - .use('md-demo-loader') - .loader(loaderPath) - .options({ - ...loaderBaseOpts, - mode: 'demo', - }) - .end() - .end() - // get page demo-index for each markdown file - .oneOf('md-demo-index') - .resourceQuery(/demo-index$/) - .use('md-demo-index-loader') - .loader(loaderPath) - .options({ - ...loaderBaseOpts, - mode: 'demo-index', - }) - .end() - .end() - // get page frontmatter for each markdown file - .oneOf('md-frontmatter') - .resourceQuery(/frontmatter$/) - .use('md-frontmatter-loader') - .loader(loaderPath) - .options({ - ...loaderBaseOpts, - mode: 'frontmatter', - }) - .end() - .end() - // get page text for each markdown file - .oneOf('md-text') - .resourceQuery(/text$/) - .use('md-text-loader') - .loader(loaderPath) - .options({ - ...loaderBaseOpts, - mode: 'text', - }) - .end() - .end() - // get page scope for each markdown file - .oneOf('md-scope') - .resourceQuery(/scope$/) - .use('babel-loader') - .loader(babelInUmi.loader) - .options(babelInUmi.options) - .end() - .use('md-scope-loader') - .loader(loaderPath) - .options({ - ...loaderBaseOpts, - mode: 'scope', - }) - .end() - .end() - // get page scope-index for each markdown file - .oneOf('md-scope-index') - .resourceQuery(/scope-index$/) - .use('md-scope-index-loader') - .loader(loaderPath) - .options({ - ...loaderBaseOpts, - mode: 'scope-index', - }) - .end() - .end() - // get page component for each markdown file + .test(/\.md$/); + + // generate independent oneOf rules + ['frontmatter', 'text', 'demo-index', 'scope-index'].forEach((type) => { + mdRule + .oneOf(`md-${type}`) + .resourceQuery(new RegExp(`${type}$`)) + .use(`md-${type}-loader`) + .loader(loaderPath) + .options({ + ...loaderBaseOpts, + mode: type, + }); + }); + + // generate oneOf rules with babel loader + ['demo', 'scope'].forEach((type) => { + mdRule + .oneOf(`md-${type}`) + .resourceQuery(new RegExp(`${type}$`)) + .use('babel-loader') + .loader(babelInUmi.loader) + .options(babelInUmi.options) + .end() + .use(`md-${type}-loader`) + .loader(loaderPath) + .options({ + ...loaderBaseOpts, + mode: type, + }) + .end() + .end(); + }); + + // get page component for each markdown file + mdRule .oneOf('md') .use('babel-loader') .loader(babelInUmi.loader) @@ -155,7 +115,7 @@ export default (api: IApi) => { .rule('dumi-page') .type('javascript/auto') .test(/\.(j|t)sx?$/) - .resourceQuery(/(meta|frontmatter)$/) + .resourceQuery(/frontmatter$/) .use('page-meta-loader') .loader(require.resolve('../../loaders/page')); diff --git a/src/features/exports.ts b/src/features/exports.ts index c958373e58..b993b0d805 100644 --- a/src/features/exports.ts +++ b/src/features/exports.ts @@ -1,4 +1,5 @@ import type { IApi } from '@/types'; +import path from 'path'; import { winPath } from 'umi/plugin-utils'; export default (api: IApi) => { @@ -7,6 +8,7 @@ export default (api: IApi) => { // allow import from dumi api.modifyConfig((memo) => { memo.alias['dumi$'] = '@@/dumi/exports'; + memo.alias['dumi/dist'] = winPath(path.join(__dirname, '..')); return memo; }); @@ -18,8 +20,7 @@ export default (api: IApi) => { path: 'dumi/exports.ts', content: `export * from '../exports'; export * from '${winPath(require.resolve('../client/theme-api'))}'; -export { getRouteMetaById } from './meta/route-meta'; -export { loadFilesMeta } from './meta/search'; +export * from './meta/exports'; export { getDemoScopesById } from './live/demo-scopes';`, }); }); diff --git a/src/features/meta.ts b/src/features/meta.ts index ee255be952..243ed79efd 100644 --- a/src/features/meta.ts +++ b/src/features/meta.ts @@ -9,10 +9,15 @@ import { isTabRouteFile } from './tabs'; export const TABS_META_PATH = 'dumi/meta/tabs.ts'; export const ATOMS_META_PATH = 'dumi/meta/atoms.ts'; -type MetaFiles = { index: number; file: string; id: string }[]; +type IMetaFiles = { + index: number; + file: string; + id: string; + isMarkdown?: boolean; +}[]; export default (api: IApi) => { - const metaFiles: MetaFiles = []; + const metaFiles: IMetaFiles = []; api.register({ key: 'modifyRoutes', @@ -50,50 +55,23 @@ export default (api: IApi) => { content: 'export const components = null;', }); - const parsedMetaFiles: MetaFiles = await api.applyPlugins({ + const parsedMetaFiles: IMetaFiles = await api.applyPlugins({ type: api.ApplyPluginsType.modify, key: 'dumi.modifyMetaFiles', initialValue: JSON.parse(JSON.stringify(metaFiles)), }); - api.writeTmpFile({ - noPluginDir: true, - path: 'dumi/meta/search.ts', - tplPath: winPath(join(TEMPLATES_DIR, 'meta-search.ts.tpl')), - context: { - metaFiles: parsedMetaFiles, - }, + // mark isMarkdown flag + parsedMetaFiles.forEach((metaFile) => { + metaFile.isMarkdown = metaFile.file.endsWith('.md'); }); api.writeTmpFile({ noPluginDir: true, - path: 'dumi/meta/frontmatter.ts', - tplPath: winPath(join(TEMPLATES_DIR, 'meta-frontmatter.ts.tpl')), + path: 'dumi/meta/index.ts', + tplPath: winPath(join(TEMPLATES_DIR, 'meta/index.ts.tpl')), context: { metaFiles: parsedMetaFiles, - }, - }); - - // generate meta lazy entry - const mdFiles = parsedMetaFiles.filter((metaFile) => - metaFile.file.endsWith('.md'), - ); - - api.writeTmpFile({ - noPluginDir: true, - path: 'dumi/meta/demos.ts', - tplPath: winPath(join(TEMPLATES_DIR, 'meta-demos.ts.tpl')), - context: { - metaFiles: mdFiles, - }, - }); - - api.writeTmpFile({ - noPluginDir: true, - path: 'dumi/meta/route-meta.ts', - tplPath: winPath(join(TEMPLATES_DIR, 'meta-route.ts.tpl')), - context: { - metaFiles: mdFiles, chunkName: function chunkName(this) { if (!('file' in this)) { return ''; @@ -111,11 +89,19 @@ export default (api: IApi) => { api.writeTmpFile({ noPluginDir: true, path: 'dumi/meta/runtime.ts', - tplPath: winPath(join(TEMPLATES_DIR, 'meta-runtime.ts.tpl')), + tplPath: winPath(join(TEMPLATES_DIR, 'meta/runtime.ts.tpl')), context: { deepmerge: winPath(path.dirname(require.resolve('deepmerge/package'))), }, }); + + // generate exports api + api.writeTmpFile({ + noPluginDir: true, + path: 'dumi/meta/exports.ts', + tplPath: winPath(join(TEMPLATES_DIR, 'meta/exports.ts.tpl')), + context: {}, + }); }); api.addRuntimePlugin(() => '@@/dumi/meta/runtime.ts'); diff --git a/src/loaders/markdown/index.ts b/src/loaders/markdown/index.ts index e378d9bb3f..dd7c0cb01d 100644 --- a/src/loaders/markdown/index.ts +++ b/src/loaders/markdown/index.ts @@ -83,8 +83,9 @@ function emitDefault( ret: IMdTransformerResult, ) { const { frontmatter, demos } = ret.meta; - // do not wrap DumiPage for tab content const isTabContent = isTabRouteFile(this.resourcePath); + // do not wrap DumiPage for tab content + const wrapper = isTabContent ? '' : 'DumiPage'; // apply demos resolve hook if (demos && opts.onResolveDemos) { @@ -100,20 +101,23 @@ function emitDefault( return `${Object.values(opts.builtins) .map((item) => `import ${item.specifier} from '${item.source}';`) .join('\n')} -import React from 'react'; -${ - isTabContent - ? `import { useTabMeta } from 'dumi';` - : `import { DumiPage, useRouteMeta } from 'dumi';` +import LoadingComponent from '@@/dumi/theme/loading'; +import React, { Suspense } from 'react'; +import { DumiPage, useTabMeta, useRouteMeta } from 'dumi'; + +function DumiMarkdownInner() { + const { texts: ${CONTENT_TEXTS_OBJ_NAME} } = use${ + isTabContent ? 'TabMeta' : 'RouteMeta' + }(); + + return ${ret.content}; } // export named function for fastRefresh // ref: https://github.com/pmmmwh/react-refresh-webpack-plugin/blob/main/docs/TROUBLESHOOTING.md#edits-always-lead-to-full-reload function DumiMarkdownContent() { - const { texts: ${CONTENT_TEXTS_OBJ_NAME} } = use${ - isTabContent ? 'TabMeta' : 'RouteMeta' - }(); - return ${isTabContent ? ret.content : `${ret.content}`}; + // wrap suspense for catch async meta data + return <${wrapper}>}>; } export default DumiMarkdownContent;`; @@ -192,26 +196,26 @@ function emitFrontmatter( opts: IMdLoaderFrontmatterModeOptions, ret: IMdTransformerResult, ) { - const { frontmatter } = ret.meta; - - return Mustache.render(`export const frontmatter = {{{frontmatter}}};`, { - frontmatter: JSON.stringify(frontmatter), - }); -} - -function emitText(opts: IMdLoaderTextModeOptions, ret: IMdTransformerResult) { - const { texts, toc } = ret.meta; + const { frontmatter, toc } = ret.meta; return Mustache.render( `export const toc = {{{toc}}}; -export const texts = {{{texts}}};`, +export const frontmatter = {{{frontmatter}}};`, { toc: JSON.stringify(toc), - texts: JSON.stringify(texts), + frontmatter: JSON.stringify(frontmatter), }, ); } +function emitText(opts: IMdLoaderTextModeOptions, ret: IMdTransformerResult) { + const { texts } = ret.meta; + + return Mustache.render(`export const texts = {{{texts}}};`, { + texts: JSON.stringify(texts), + }); +} + function emitScope(opts: IMdLoaderOptions, ret: IMdTransformerResult) { const { demos } = ret.meta; diff --git a/src/loaders/page/index.ts b/src/loaders/page/index.ts index 9e8f808822..61967eb973 100644 --- a/src/loaders/page/index.ts +++ b/src/loaders/page/index.ts @@ -14,7 +14,5 @@ export default function pageMetaLoader(this: any, raw: string) { frontmatter.title ??= lodash.startCase(path.basename(pathWithoutIndex)); return `export const frontmatter = ${JSON.stringify(frontmatter)}; -export const toc = []; -export const texts = []; -export const demos = {};`; +export const toc = [];`; } diff --git a/src/templates/ContextWrapper.ts.tpl b/src/templates/ContextWrapper.ts.tpl index 4d7d4e6878..02306a3e87 100644 --- a/src/templates/ContextWrapper.ts.tpl +++ b/src/templates/ContextWrapper.ts.tpl @@ -3,8 +3,6 @@ import { useOutlet, history } from 'dumi'; import { warning } from 'rc-util'; import { SiteContext, type ISiteContext } from '{{{contextPath}}}'; import { components } from '../meta/atoms'; -import { tabs } from '../meta/tabs'; -import { getDemoById } from '../meta/demos'; import { locales } from '../locales/config'; {{{defaultExport}}} {{{namedExport}}} @@ -48,20 +46,18 @@ export default function DumiContextWrapper() { entryExports, demos: null, components, - tabs, locales, loading, setLoading, hostname, themeConfig, _2_level_nav_available, - getDemoById, }; // Proxy do not warning since `Object.keys` will get nothing to loop Object.defineProperty(ctx, 'demos', { get: () => { - warning(false, '`demos` return empty in latest version.'); + warning(false, '`demos` return empty in latest version, please use `useDemo` instead.'); return {}; }, }); @@ -72,21 +68,17 @@ export default function DumiContextWrapper() { historyType, entryExports, components, - tabs, locales, loading, setLoading, hostname, themeConfig, _2_level_nav_available, - getDemoById, ]); - - return ( {outlet} ); -} \ No newline at end of file +} diff --git a/src/templates/meta-demos.ts.tpl b/src/templates/meta-demos.ts.tpl deleted file mode 100644 index 89125a93f1..0000000000 --- a/src/templates/meta-demos.ts.tpl +++ /dev/null @@ -1,34 +0,0 @@ -{{#metaFiles}} -import { demoIndex as dmi{{{index}}} } from '{{{file}}}?type=demo-index'; -{{/metaFiles}} - -export const demoIndexes: Record Promise }> = { - {{#metaFiles}} - '{{{id}}}': dmi{{{index}}}, - {{/metaFiles}} -}; - -// Convert the demoIndex to a key-value pairs: -const demoIdMap = Object.keys(demoIndexes).reduce((total, current) => { - const demoIndex = demoIndexes[current]; - const { ids, getter } = demoIndex; - - ids.forEach((id) => { - total[id] = getter; - }); - - return total; -}, {}); - -/** Async to load demo by id */ -export const getDemoById = async (id: string) => { - const getter = demoIdMap[id]; - - if (!getter) { - return null; - } - - const { demos }: any = await getter() || {}; - - return demos?.[id]; -}; diff --git a/src/templates/meta-frontmatter.ts.tpl b/src/templates/meta-frontmatter.ts.tpl deleted file mode 100644 index 7127988831..0000000000 --- a/src/templates/meta-frontmatter.ts.tpl +++ /dev/null @@ -1,9 +0,0 @@ -{{#metaFiles}} -import { frontmatter as fm{{{index}}} } from '{{{file}}}?type=frontmatter'; -{{/metaFiles}} - -export const filesFrontmatter = { - {{#metaFiles}} - '{{{id}}}': fm{{{index}}}, - {{/metaFiles}} -} diff --git a/src/templates/meta-route.ts.tpl b/src/templates/meta-route.ts.tpl deleted file mode 100644 index 5806905f8e..0000000000 --- a/src/templates/meta-route.ts.tpl +++ /dev/null @@ -1,43 +0,0 @@ -import { tabs } from './tabs'; -import { filesFrontmatter } from './frontmatter'; - -const files = { -{{#metaFiles}} - '{{{id}}}': { - textGetter: () => import({{{chunkName}}}'{{{file}}}?type=text'), - {{#tabs}} - tabs: {{{tabs}}}, - {{/tabs}} - }, -{{/metaFiles}} -}; - -export const getRouteMetaById = async (id: string) => { - const file = files[id]; - - if (!file) { - return null; - } - - const text = await file.textGetter(); - const frontmatter = filesFrontmatter[id]; - - const tabsMeta = file.tabs && await Promise.all(file.tabs.map(async (tab) => { - const meta = await getRouteMetaById(tab) ?? { - frontmatter: { title: tabs[tab].title }, - toc: [], - texts: [], - }; - return { - ...tabs[tab], - meta, - } - })); - - return { - texts: text?.texts, - toc: text?.toc, - frontmatter, - tabs: tabsMeta, - }; -} diff --git a/src/templates/meta-search.ts.tpl b/src/templates/meta-search.ts.tpl deleted file mode 100644 index 61c349db29..0000000000 --- a/src/templates/meta-search.ts.tpl +++ /dev/null @@ -1,41 +0,0 @@ -// This will bundle all the site demos and meta data into one file -// which should only async load on search -import { getRouteMetaById } from './route-meta'; -import { demoIndexes } from './demos'; -import { filesFrontmatter } from './frontmatter'; - -// generate demos data in runtime, for reuse route.id to reduce bundle size -export const demos = {}; - -/** @private Internal usage. Safe to refactor. */ -export async function loadFilesMeta(idList: string[]) { - const metaMap: Record = {}; - - {{#metaFiles}} - if (idList.includes('{{{id}}}')) { - metaMap['{{{id}}}'] = async () => { - const routeMeta = await getRouteMetaById('{{{id}}}'); - const demo = await demoIndexes['{{{id}}}']?.getter() || {}; - return { - frontmatter: filesFrontmatter['{{{id}}}'] ?? {}, - toc: routeMeta?.toc ?? [], - texts: routeMeta?.texts ?? [], - tabs: routeMeta?.tabs ?? [], - demos: demo?.demos ?? {}, - }; - }; - } - {{/metaFiles}} - - // Wait for all meta data to be loaded - const metaList = await Promise.all(Object.entries(metaMap).map(([id, getter]) => getter())); - - // Merge into filesMeta - const filesMeta = {}; - - Object.entries(metaMap).forEach(([id], index) => { - filesMeta[id] = metaList[index]; - }); - - return filesMeta; -} diff --git a/src/templates/meta/exports.ts.tpl b/src/templates/meta/exports.ts.tpl new file mode 100644 index 0000000000..9d304811a2 --- /dev/null +++ b/src/templates/meta/exports.ts.tpl @@ -0,0 +1,134 @@ +import { filesMeta, tabsMeta } from '.'; +import type { IDemoData, IRouteMeta } from 'dumi/dist/client/theme-api/types'; +import { use } from 'dumi/dist/client/theme-api/utils'; + +const demoIdMap = Object.keys(filesMeta).reduce((total, current) => { + if (filesMeta[current].demoIndex) { + const { ids, getter } = filesMeta[current].demoIndex; + + ids.forEach((id) => { + total[id] = getter; + }); + } + + return total; +}, {}); + +const demosCache = new Map>(); + +/** + * use demo data by id + */ +export function useDemo(id: string): IDemoData | undefined { + if (!demosCache.get(id)) { + demosCache.set( + id, + demoIdMap[id]?.().then(({ demos }) => demos[id]), + ); + } + + return use(demosCache.get(id)!); +} + +/** + * get all demos + */ +export async function getFullDemos() { + const demoFilesMeta = Object.entries(filesMeta).filter( + ([_id, meta]) => meta.demoIndex, + ); + + return Promise.all( + demoFilesMeta.map(async ([id, meta]) => ({ + id, + demos: (await meta.demoIndex.getter()).demos as Record, + })), + ).then((ret) => + ret.reduce>((total, { id, demos }) => { + Object.values(demos).forEach((demo) => { + demo.routeId = id; + }); + + return { + ...total, + ...demos, + }; + }, {}), + ); +} + +type ITab = NonNullable[0]; + +/** + * generate final data for tab + */ +function genTab(id: string, meta?: ITab['meta']): ITab { + return { + ...tabsMeta[id], + meta: meta ?? { + frontmatter: { title: tabsMeta[id].title }, + toc: [], + texts: [], + }, + }; +} + +/** + * get route meta by id + */ +export function getRouteMetaById( + id: string, + opts?: T, +): T extends { syncOnly: true } + ? undefined | IRouteMeta + : Promise | undefined { + if (filesMeta[id]) { + const { frontmatter, toc, textGetter, tabs = [] } = filesMeta[id]; + const routeMeta: IRouteMeta = { + frontmatter, + toc: toc, + texts: [], + }; + + if (opts?.syncOnly) { + routeMeta.tabs = tabs.map((tabId) => + genTab(tabId, getRouteMetaById(tabId, opts)), + ); + } else { + return new Promise(async (resolve) => { + if (textGetter) { + ({ texts: routeMeta.texts } = await textGetter()); + } + + routeMeta.tabs = await Promise.all( + tabs.map(async (tabId) => + genTab(tabId, await getRouteMetaById(tabId, opts)), + ), + ); + resolve(routeMeta); + }); + } + + return routeMeta; + } +} + +/** + * get all routes meta + */ +export async function getFullRoutesMeta(): Promise> { + return await Promise.all( + Object.keys(filesMeta).map(async (id) => ({ + id, + meta: await getRouteMetaById(id), + })), + ).then((ret) => + ret.reduce( + (total, { id, meta }) => ({ + ...total, + [id]: meta, + }), + {}, + ), + ); +} diff --git a/src/templates/meta/index.ts.tpl b/src/templates/meta/index.ts.tpl new file mode 100644 index 0000000000..9697f4b2fa --- /dev/null +++ b/src/templates/meta/index.ts.tpl @@ -0,0 +1,26 @@ +{{#metaFiles}} +import { frontmatter as fm{{{index}}}, toc as t{{{index}}} } from '{{{file}}}?type=frontmatter'; +{{#isMarkdown}} +import { demoIndex as dmi{{{index}}} } from '{{{file}}}?type=demo-index'; +{{/isMarkdown}} +{{/metaFiles}} + +export const filesMeta = { + {{#metaFiles}} + '{{{id}}}': { + frontmatter: fm{{{index}}}, + toc: t{{{index}}}, + {{#isMarkdown}} + demoIndex: dmi{{{index}}}, + {{/isMarkdown}} + {{#tabs}} + tabs: {{{tabs}}}, + {{/tabs}} + {{#isMarkdown}} + textGetter: () => import({{{chunkName}}}'{{{file}}}?type=text'), + {{/isMarkdown}} + }, + {{/metaFiles}} +} + +export { tabs as tabsMeta } from './tabs'; diff --git a/src/templates/meta-runtime.ts.tpl b/src/templates/meta/runtime.ts.tpl similarity index 50% rename from src/templates/meta-runtime.ts.tpl rename to src/templates/meta/runtime.ts.tpl index 3e334ae818..4946b7db8c 100644 --- a/src/templates/meta-runtime.ts.tpl +++ b/src/templates/meta/runtime.ts.tpl @@ -1,13 +1,12 @@ import { warning } from 'rc-util'; -import { tabs } from './tabs'; -import { filesFrontmatter } from './frontmatter'; import deepmerge from '{{{deepmerge}}}'; +import { getRouteMetaById } from './exports'; // Proxy do not warning since `Object.keys` will get nothing to loop function wrapEmpty(meta, fieldName, defaultValue) { Object.defineProperty(meta, fieldName, { get: () => { - warning(false, `'${fieldName}' return empty in latest version.`); + warning(false, `'${fieldName}' return empty in latest version, please use \`useRouteMeta\` instead.`); return defaultValue; }, }); @@ -15,32 +14,22 @@ function wrapEmpty(meta, fieldName, defaultValue) { export const patchRoutes = ({ routes }) => { Object.values(routes).forEach((route) => { - if (filesFrontmatter[route.id]) { - if (process.env.NODE_ENV === 'production' && (route.meta?.frontmatter?.debug || filesFrontmatter[route.id].debug)) { + const routeMeta = getRouteMetaById(route.id, { syncOnly: true }); + + if (routeMeta) { + if (process.env.NODE_ENV === 'production' && (route.meta?.frontmatter?.debug || routeMeta.debug)) { // hide route in production which set hide frontmatter delete routes[route.id]; } else { // merge meta to route object - route.meta = deepmerge(route.meta, { frontmatter: filesFrontmatter[route.id] }); + route.meta = deepmerge(route.meta, routeMeta); - wrapEmpty(route.meta, 'demos', {}); + wrapEmpty(route.meta, 'toc', []); wrapEmpty(route.meta, 'texts', []); - // apply real tab data from id - route.meta.tabs = route.meta.tabs?.map((id) => { - const meta = { - frontmatter: filesFrontmatter[id] || { title: tabs[id].title }, - toc: [], - texts: [], - } - - wrapEmpty(meta, 'demos', {}); - wrapEmpty(meta, 'texts', []); - - return { - ...tabs[id], - meta, - } + route.meta.tabs?.forEach((tab) => { + wrapEmpty(tab, 'toc', []); + wrapEmpty(tab, 'texts', []); }); } } diff --git a/tsconfig.json b/tsconfig.json index 546939b35b..831f3d0ae6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,8 @@ "baseUrl": "./", "paths": { "@/*": ["src/*"], - "@@/*": [".dumi/tmp/*"] + "@@/*": [".dumi/tmp/*"], + "dumi/dist/*": ["src/*"] }, "types": ["vitest/globals"] },