Skip to content

Commit a2580e7

Browse files
authored
feat: support conventional 2-level navigation (#1665)
* feat: implement conventional 2-level navigation * feat: support to configure subType for atomDirs * refactor: remove useless entityDirs schema * docs: describe 2-level navigation usage * docs: describe 2-level navigation frontmatter * refactor: improve styles for nav dropdown * docs: add faq for more than 2-level nav * refactor: improve transition for nav dropdown triangle * docs: add version badge for 2-level nav
1 parent f9ba285 commit a2580e7

File tree

13 files changed

+436
-69
lines changed

13 files changed

+436
-69
lines changed

docs/config/index.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,12 @@ export default defineConfig({
3131

3232
#### atomDirs
3333

34-
- 类型:`{ type: string; dir: string }[]`
34+
- 类型:`{ type: string; subType?: string; dir: string }[]`
3535
- 默认值:`[{ type: 'component', dir: 'src' }]`
3636

37-
配置原子资产(例如组件、函数、工具等)Markdown 的解析目录,该目录下 **第一层级** 的 Markdown 文档会被解析为该实体分类下的路由,嵌套层级将不会识别。比如在默认配置下,`src/Foo/index.md` 将被解析为 `components/foo` 的路由。
37+
配置原子资产(例如组件、函数、工具等)Markdown 的解析目录。
38+
39+
其中 `type` 用于指定资产类别,必须是 URL 友好的**单数单词**,比如 `component` 或者 `hook``subType` 用于指定资产的子类别,通常在需要生成二级导航时使用,值必须为 URL 友好的单词;`dir` 指定目录下**第一层级**的 Markdown 文档会被解析为该实体分类下的路由,嵌套层级将不会识别。比如在默认配置下,`src/Foo/index.md` 将被解析为 `components/foo` 的路由,`type` 配置值将**自动被复数化**后作为路由的前缀路径。
3840

3941
单独将资产的解析逻辑拆分是为了解决 dumi 1 中普通文档与源码目录下的组件文档混淆不清、分组困难的问题。
4042

docs/config/markdown.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,32 @@ title: 配置页面标题
8282

8383
配置页面简介,该值会用于生成 `<meta>` 标签。
8484

85+
## nav
86+
87+
- 类型:`string | { title: string; order: number; parent: { title: string; order: string } }`
88+
- 默认值:`undefined`
89+
90+
配置当前页所属的一级导航及二级导航,同一导航类目下仅需配置任意一个 Markdown 文件即可全局生效,未配置时将会使用[默认规则](../guide/conventional-routing.md#导航归类及生成)
91+
92+
例如:
93+
94+
```md
95+
---
96+
# 单独配置名称
97+
nav: 名称
98+
# 同时配置名称和顺序,order 越小越靠前,默认为 0
99+
nav:
100+
title: 名称
101+
order: 1
102+
# 单独配置二级导航名称
103+
parent: 父级名称
104+
# 同时配置二级导航名称和顺序,order 越小越靠前,默认为 0
105+
parent:
106+
title: 父级名称
107+
order: 1
108+
---
109+
```
110+
85111
## group
86112

87113
- 类型:`string | { title: string; order: number }`

docs/guide/conventional-routing.md

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,46 @@ nav:
5252
---
5353
```
5454

55-
需要注意的是,目前 dumi 尚不支持二级导航,所以如果路由路径存在超过 2 层嵌套,则会会从第 2 层开始形成新的一级导航,这并非推荐的用法,请耐心等待 dumi 对二级导航的原生支持。
55+
### 约定式二级导航 <Badge>2.2.0+</Badge>
56+
57+
同时,为了便于组织文档,dumi 还支持生成二级导航,使用起来也非常简单,以如下目录结构为例:
58+
59+
```bash
60+
docs
61+
└── platforms
62+
   ├── pc
63+
   │   ├── index.md
64+
   │   └── faq.md
65+
   └── mobile
66+
      ├── index.md
67+
      └── faq.md
68+
```
69+
70+
根据一级导航的规则,上面所有的 Markdown 必然都归属于 `Platforms` 导航,但同时它们还会分别归属于 `Pc``Mobile` 这两个二级导航,这是因为这些路由路径除了拥有共同的 `/platforms` 前缀外,还拥有各自的二级路径前缀,即 `/pc``/mobile`,二级导航的 UI 效果如下:
71+
72+
<img src="https://gw.alipayobjects.com/zos/bmw-prod/85a246ef-5f74-4f70-97fe-f6b40968e0bf/lhpzbiod_w288_h232.jpeg" width="140" />
73+
74+
二级导航的名称及顺序的默认规则与一级导航一致,类似的,我们也可以在该二级导航类目下**任一文档**的 Markdown 源文件头部通过 FrontMatter 指定,比如:
75+
76+
```md
77+
---
78+
nav:
79+
# 单独设置二级导航名称
80+
parent: 移动端
81+
# 同时设置二级导航名称和顺序,order 越小越靠前,默认为 0
82+
parent:
83+
title: 移动端
84+
order: 1
85+
---
86+
```
87+
88+
最后,在创建约定式二级导航时,还有一些规则是需要我们注意的:
89+
90+
1. 在相同一级路径下,二级及三级路径存在 2 组及以上时才能形成二级导航,比如上述例子中如果 `mobile` 文件夹不存在,则不会形成二级导航,但倘若添加 `platforms/xxx.md` 又可以形成二级导航;
91+
2. 在相同一级路径下,如果仅存在三级路径,则只展示下拉菜单,一级导航本身不带超链,比如上述例子中的 `Platforms` 导航就不带超链;
92+
3. 在相同一级路径下,在 2 的基础上还存在二级或一级路径,则一级导航除了下拉菜单外也带超链,比如上述例子中如果添加 `platforms/xx.md``Platforms` 带超链;
93+
4. 不同级导航之间的侧边菜单是完全隔离的,比如上述例子中 `Platforms``Pc``Mobile` 都拥有不同的菜单;
94+
5. 资产路由本身不支持嵌套,但我们仍然可以通过 [`resolve.atomDirs[n].subType`](../config/index.md#resolve) 配置项实现二级导航,比如 `[{ type: 'component', subType: 'pc' dir: 'src' }]` 会为 `src/xx/index.md` 生成 `/components/pc/xx` 路由,以满足二级导航的路径规则。
5695

5796
## 菜单归类及生成
5897

docs/guide/faq.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,3 +204,7 @@ import './index.css';
204204
```
205205

206206
这样无论是 dumi 还是实际项目里,都不需要做额外配置,但这种做法也有一些限制:如果引入的是 `.less`,那么目标项目的开发框架必须支持编译 Less。
207+
208+
## 是否支持三级导航?
209+
210+
不支持。如果文档目录结构的复杂度超过 3 级,应该考虑优化文档整体结构而非使用三级导航。如果有特殊场景需要,可以自定义主题实现。

src/client/theme-api/types.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,13 @@ export interface IRouteMeta {
6161
description?: string;
6262
keywords?: string[];
6363
// render related
64-
nav?: string | { title?: string; order?: number };
64+
nav?:
65+
| string
66+
| {
67+
title?: string;
68+
order?: number;
69+
parent?: Omit<IRouteMeta['frontmatter']['nav'], 'parent'>;
70+
};
6571
group?: string | { title?: string; order?: number };
6672
order?: number;
6773
hero?: {
@@ -146,7 +152,7 @@ export type ILocalesConfig = ILocale[];
146152

147153
export interface INavItem {
148154
title: string;
149-
link: string;
155+
link?: string;
150156
order?: number;
151157
activePath?: string;
152158
[key: string]: any;

src/client/theme-api/useNavData.ts

Lines changed: 93 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,34 @@
11
import { useFullSidebarData, useLocale, useSiteData } from 'dumi';
22
import { useState } from 'react';
3-
import type { INavItems, IUserNavItems, IUserNavMode } from './types';
3+
import type {
4+
INavItems,
5+
ISidebarGroup,
6+
IUserNavItems,
7+
IUserNavMode,
8+
} from './types';
49
import {
510
getLocaleNav,
611
pickRouteSortMeta,
712
useLocaleDocRoutes,
813
useRouteDataComparer,
914
} from './utils';
1015

16+
type INavSortMeta = Partial<Pick<INavItems[0], 'order' | 'title'>>;
17+
18+
function genNavItem(
19+
meta: INavSortMeta,
20+
groups: ISidebarGroup[],
21+
activePath: string,
22+
link?: string,
23+
): INavItems[0] {
24+
return {
25+
title: meta.title || groups[0].title || groups[0].children[0].title,
26+
order: meta.order || 0,
27+
activePath: activePath,
28+
...(link ? { link } : {}),
29+
};
30+
}
31+
1132
/**
1233
* hook for get nav data
1334
*/
@@ -37,28 +58,80 @@ export const useNavData = () => {
3758
}
3859

3960
// fallback to generate nav data from sidebar data
40-
const data = Object.entries(sidebar).map<INavItems[0]>(([link, groups]) => {
41-
const meta = Object.values(routes).reduce<{
42-
title?: string;
43-
order?: number;
44-
}>((ret, route) => {
45-
// find routes which within the nav path
46-
if (route.path!.startsWith(link.slice(1))) {
47-
pickRouteSortMeta(ret, 'nav', route.meta!.frontmatter);
48-
}
49-
return ret;
50-
}, {});
61+
const data = Object.values(
62+
Object.entries(sidebar)
63+
// make sure shallow nav item before deep
64+
.sort(([a], [b]) => a.split('/').length - b.split('/').length)
65+
// convert sidebar data to nav data
66+
.reduce<Record<string, INavItems[0]>>((ret, [link, groups]) => {
67+
const [, parentPath, restPath] = link.match(/^(\/[^/]+)([^]+)?$/)!;
68+
const isNestedNav = Boolean(restPath);
69+
const [rootMeta, parentMeta] = Object.values(routes).reduce<
70+
{
71+
title?: string;
72+
order?: number;
73+
}[]
74+
>(
75+
(ret, route) => {
76+
// find routes which within the nav path
77+
if (route.path!.startsWith(link.slice(1))) {
78+
pickRouteSortMeta(ret[0], 'nav', route.meta!.frontmatter);
79+
// generate parent meta for nested nav
80+
if (isNestedNav)
81+
pickRouteSortMeta(
82+
ret[1],
83+
'nav.parent',
84+
route.meta!.frontmatter,
85+
);
86+
}
87+
return ret;
88+
},
89+
[{}, {}],
90+
);
5191

52-
return {
53-
title: meta.title || groups[0].title || groups[0].children[0].title,
54-
order: meta.order || 0,
55-
link: groups[0].children[0].link,
56-
activePath: link,
57-
};
58-
});
92+
if (isNestedNav) {
93+
// fallback to use parent path as title
94+
parentMeta.title ??= parentPath
95+
.slice(1)
96+
.replace(/^[a-z]/, (s) => s.toUpperCase());
97+
98+
// handle nested nav item as parent children
99+
const parent = (ret[parentPath] ??= genNavItem(
100+
parentMeta,
101+
groups,
102+
parentPath,
103+
));
104+
105+
parent.children ??= [];
106+
ret[parentPath].children!.push(
107+
genNavItem(rootMeta, groups, link, groups[0].children[0].link),
108+
);
109+
} else {
110+
// handle root nav item
111+
ret[link] = genNavItem(
112+
rootMeta,
113+
groups,
114+
link,
115+
groups[0].children[0].link,
116+
);
117+
}
59118

119+
return ret;
120+
}, {}),
121+
);
122+
123+
data.forEach((item, i) => {
124+
if (!item.link && item.children?.length === 1) {
125+
// hoist nav item if only one child
126+
data[i] = item.children[0];
127+
} else if (item.children) {
128+
// sort nav item children by order or title
129+
item.children.sort(sidebarDataComparer);
130+
}
131+
});
132+
// sort nav items by order or title
60133
data.sort(sidebarDataComparer);
61-
// TODO: 2-level nav data
134+
62135
if (mode === 'prepend') data.unshift(...userNavValue);
63136
else if (mode === 'append') data.push(...userNavValue);
64137

src/client/theme-api/useSidebarData.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useLocale, useLocation, useSiteData } from 'dumi';
1+
import { useLocale, useLocation, useRouteMeta, useSiteData } from 'dumi';
22
import { useState } from 'react';
33
import type {
44
ILocalesConfig,
@@ -20,6 +20,25 @@ const getLocaleClearPath = (routePath: string, locale: ILocalesConfig[0]) => {
2020
: routePath;
2121
};
2222

23+
/**
24+
* get parent path from route path
25+
*/
26+
function getRouteParentPath(path: string, isIndexRoute?: boolean) {
27+
const paths = path.split('/');
28+
const sliceEnd = Math.min(
29+
Math.max(
30+
// increase 1 level if route file is index.md
31+
isIndexRoute ? paths.length : paths.length - 1,
32+
// least 1-level
33+
1,
34+
),
35+
// up to 2-level
36+
2,
37+
);
38+
39+
return paths.slice(0, sliceEnd).join('/');
40+
}
41+
2342
/**
2443
* hook for get sidebar data for all nav
2544
*/
@@ -40,12 +59,19 @@ export const useFullSidebarData = () => {
4059
// skip index routes
4160
if (clearPath && route.meta) {
4261
// extract parent path from route path
43-
// a => /a
44-
// en-US/a => /en-US/a
45-
// a/b => /a
46-
// en-US/a/b => /en-US/a
62+
// normal examples:
63+
// a => /a
64+
// en-US/a => /en-US/a
65+
// a/b => /a
66+
// en-US/a/b => /en-US/a
67+
// convention 2-level navs examples:
68+
// a/b => /a/b (if route file is a/b/index.md)
69+
// a/b/c => /a/b
4770
const parentPath = `/${route.path!.replace(clearPath, (s) =>
48-
s.replace(/\/[^/]+$/, ''),
71+
getRouteParentPath(
72+
s,
73+
route.meta!.frontmatter.filename?.endsWith('index.md'),
74+
),
4975
)}`;
5076
const { title, order } = pickRouteSortMeta(
5177
{ order: 0 },
@@ -176,6 +202,7 @@ export const useSidebarData = () => {
176202
const locale = useLocale();
177203
const sidebar = useFullSidebarData();
178204
const { pathname } = useLocation();
205+
const { frontmatter } = useRouteMeta();
179206
const clearPath = getLocaleClearPath(pathname.slice(1), locale);
180207
// extract parent path from location pathname
181208
// /a => /a
@@ -185,7 +212,7 @@ export const useSidebarData = () => {
185212
// /en-US/a/b/ => /en-US/a (also strip trailing /)
186213
const parentPath = clearPath
187214
? pathname.replace(clearPath, (s) =>
188-
s.replace(/([^/]+)(\/[^/]+\/?)$/, '$1'),
215+
getRouteParentPath(s, frontmatter.filename?.endsWith('index.md')),
189216
)
190217
: pathname;
191218

src/client/theme-api/utils.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,15 @@ export const useRouteDataComparer = <
108108
*/
109109
export const pickRouteSortMeta = (
110110
original: Partial<Pick<INavItem, 'order' | 'title'>>,
111-
field: 'nav' | 'group',
111+
field: 'nav' | 'nav.parent' | 'group',
112112
fm: IRouteMeta['frontmatter'],
113113
) => {
114-
const sub = fm[field];
114+
const sub: IRouteMeta['frontmatter']['group'] =
115+
field === 'nav.parent'
116+
? typeof fm.nav === 'object'
117+
? fm.nav.parent
118+
: {}
119+
: fm[field];
115120

116121
switch (typeof sub) {
117122
case 'object':

0 commit comments

Comments
 (0)