7
7
8
8
import path from "path" ;
9
9
10
+ import {
11
+ CategoryMetadataFile ,
12
+ CategoryMetadataFilenameBase ,
13
+ } from "@docusaurus/plugin-content-docs/lib/sidebars/generator" ;
10
14
import { validateCategoryMetadataFile } from "@docusaurus/plugin-content-docs/lib/sidebars/validation" ;
11
15
import { posixPath } from "@docusaurus/utils" ;
12
16
import chalk from "chalk" ;
13
17
import clsx from "clsx" ;
14
18
import fs from "fs-extra" ;
15
19
import Yaml from "js-yaml" ;
16
- import _ from "lodash" ;
17
-
18
- import type { PropSidebar } from "../types" ;
20
+ import { groupBy , uniq } from "lodash" ;
21
+ import type { DeepPartial } from "utility-types" ;
22
+
23
+ import type {
24
+ InfoPageMetadata ,
25
+ PropSidebar ,
26
+ PropSidebarItemCategory ,
27
+ } from "../types" ;
19
28
import { ApiPageMetadata } from "../types" ;
20
29
21
30
interface Options {
31
+ contentPath : string ;
22
32
sidebarCollapsible : boolean ;
23
33
sidebarCollapsed : boolean ;
24
34
}
25
35
26
- export type BaseItem = {
27
- title : string ;
28
- permalink : string ;
29
- id : string ;
30
- source : string ;
31
- sourceDirName : string ;
32
- } ;
33
-
34
- export type InfoItem = BaseItem & {
35
- type : "info" ;
36
- } ;
36
+ type keys = "type" | "title" | "permalink" | "id" | "source" | "sourceDirName" ;
37
37
38
- export type ApiItem = BaseItem & {
39
- type : "api" ;
40
- api : {
41
- info ?: {
42
- title ?: string ;
43
- } ;
44
- tags ?: string [ ] | undefined ;
45
- } ;
38
+ type InfoItem = Pick < InfoPageMetadata , keys > ;
39
+ type ApiItem = Pick < ApiPageMetadata , keys > & {
40
+ api : DeepPartial < ApiPageMetadata [ "api" ] > ;
46
41
} ;
47
42
48
43
type Item = InfoItem | ApiItem ;
@@ -55,85 +50,96 @@ function isInfoItem(item: Item): item is InfoItem {
55
50
return item . type === "info" ;
56
51
}
57
52
58
- export async function generateSidebars (
53
+ const Terminator = "." ; // a file or folder can never be "."
54
+ const BreadcrumbSeparator = "/" ;
55
+ function getBreadcrumbs ( dir : string ) {
56
+ if ( dir === Terminator ) {
57
+ // this isn't actually needed, but removing would result in an array: [".", "."]
58
+ return [ Terminator ] ;
59
+ }
60
+ return [ ...dir . split ( BreadcrumbSeparator ) . filter ( Boolean ) , Terminator ] ;
61
+ }
62
+
63
+ export async function generateSidebar (
59
64
items : Item [ ] ,
60
65
options : Options
61
66
) : Promise < PropSidebar > {
62
- const sections = _ ( items )
63
- . groupBy ( ( item ) => item . source )
64
- . mapValues ( ( items , source ) => {
65
- const prototype = items . filter ( isApiItem ) . find ( ( item ) => {
66
- return item . api ?. info != null ;
67
- } ) ;
68
- const info = prototype ?. api ?. info ;
69
- const fileName = path . basename ( source , path . extname ( source ) ) ;
70
- return {
71
- source : prototype ?. source ,
72
- sourceDirName : prototype ?. sourceDirName ?? "." ,
73
-
74
- collapsible : options . sidebarCollapsible ,
75
- collapsed : options . sidebarCollapsed ,
76
- type : "category" as const ,
77
- label : info ?. title || fileName ,
78
- items : groupByTags ( items , options ) ,
79
- } ;
80
- } )
81
- . values ( )
82
- . value ( ) ;
83
-
84
- if ( sections . length === 1 ) {
85
- return sections [ 0 ] . items ;
86
- }
87
-
88
- // group into folders and build recursive category tree
89
- const rootSections = sections . filter ( ( x ) => x . sourceDirName === "." ) ;
90
- const childSections = sections . filter ( ( x ) => x . sourceDirName !== "." ) ;
91
-
92
- const subCategories = [ ] as any ;
93
-
94
- for ( const childSection of childSections ) {
95
- const basePathRegex = new RegExp ( `${ childSection . sourceDirName } .*$` ) ;
96
- const basePath =
97
- childSection . source ?. replace ( basePathRegex , "" ) . replace ( "@site" , "." ) ??
98
- "." ;
67
+ const sourceGroups = groupBy ( items , ( item ) => item . source ) ;
68
+
69
+ let sidebar : PropSidebar = [ ] ;
70
+ let visiting = sidebar ;
71
+ for ( const items of Object . values ( sourceGroups ) ) {
72
+ if ( items . length === 0 ) {
73
+ // Since the groups are created based on the items, there should never be a length of zero.
74
+ console . warn ( chalk . yellow ( `Unnexpected empty group!` ) ) ;
75
+ continue ;
76
+ }
99
77
100
- const dirs = childSection . sourceDirName . split ( "/" ) ;
78
+ const { sourceDirName , source } = items [ 0 ] ;
101
79
102
- let root = subCategories ;
103
- const parents : string [ ] = [ ] ;
104
- while ( dirs . length ) {
105
- const currentDir = dirs . shift ( ) as string ;
106
- // todo: optimize?
107
- const folderPath = path . join ( basePath , ...parents , currentDir ) ;
108
- const meta = await readCategoryMetadataFile ( folderPath ) ;
109
- const label = meta ?. label ?? currentDir ;
110
- const existing = root . find ( ( x : any ) => x . label === label ) ;
80
+ const breadcrumbs = getBreadcrumbs ( sourceDirName ) ;
111
81
112
- if ( ! existing ) {
113
- const child = {
114
- collapsible : options . sidebarCollapsible ,
115
- collapsed : options . sidebarCollapsed ,
82
+ let currentPath = [ ] ;
83
+ for ( const crumb of breadcrumbs ) {
84
+ // We hit a spec file, create the groups for it.
85
+ if ( crumb === Terminator ) {
86
+ const title = items . filter ( isApiItem ) [ 0 ] ?. api . info ?. title ;
87
+ const fileName = path . basename ( source , path . extname ( source ) ) ;
88
+ // Title could be an empty string so `??` won't work here.
89
+ const label = ! title ? fileName : title ;
90
+ visiting . push ( {
116
91
type : "category" as const ,
117
92
label,
118
- items : [ ] ,
119
- } ;
120
- root . push ( child ) ;
121
- root = child . items ;
122
- } else {
123
- root = existing . items ;
93
+ collapsible : options . sidebarCollapsible ,
94
+ collapsed : options . sidebarCollapsed ,
95
+ items : groupByTags ( items , options ) ,
96
+ } ) ;
97
+ visiting = sidebar ; // reset
98
+ break ;
99
+ }
100
+
101
+ // Read category file to generate a label for the current path.
102
+ currentPath . push ( crumb ) ;
103
+ const categoryPath = path . join ( options . contentPath , ...currentPath ) ;
104
+ const meta = await readCategoryMetadataFile ( categoryPath ) ;
105
+ const label = meta ?. label ?? crumb ;
106
+
107
+ // Check for existing categories for the current label.
108
+ const existingCategory = visiting
109
+ . filter ( ( c ) : c is PropSidebarItemCategory => c . type === "category" )
110
+ . find ( ( c ) => c . label === label ) ;
111
+
112
+ // If exists, skip creating a new one.
113
+ if ( existingCategory ) {
114
+ visiting = existingCategory . items ;
115
+ continue ;
124
116
}
125
- parents . push ( currentDir ) ;
117
+
118
+ // Otherwise, create a new one.
119
+ const newCategory = {
120
+ type : "category" as const ,
121
+ label,
122
+ collapsible : options . sidebarCollapsible ,
123
+ collapsed : options . sidebarCollapsed ,
124
+ items : [ ] ,
125
+ } ;
126
+ visiting . push ( newCategory ) ;
127
+ visiting = newCategory . items ;
126
128
}
127
- root . push ( childSection ) ;
128
129
}
129
130
130
- return [ ...rootSections , ...subCategories ] ;
131
+ // The first group should always be a category, but check for type narrowing
132
+ if ( sidebar . length === 1 && sidebar [ 0 ] . type === "category" ) {
133
+ return sidebar [ 0 ] . items ;
134
+ }
135
+
136
+ return sidebar ;
131
137
}
132
138
133
- function groupByTags (
134
- items : Item [ ] ,
135
- { sidebarCollapsible , sidebarCollapsed } : Options
136
- ) : PropSidebar {
139
+ /**
140
+ * Takes a flat list of pages and groups them into categories based on there tags.
141
+ */
142
+ function groupByTags ( items : Item [ ] , options : Options ) : PropSidebar {
137
143
const intros = items . filter ( isInfoItem ) . map ( ( item ) => {
138
144
return {
139
145
type : "link" as const ,
@@ -143,101 +149,74 @@ function groupByTags(
143
149
} ;
144
150
} ) ;
145
151
146
- const tags = [
147
- ...new Set (
148
- items
149
- . flatMap ( ( item ) => {
150
- if ( isInfoItem ( item ) ) {
151
- return undefined ;
152
- }
153
- return item . api . tags ;
154
- } )
155
- . filter ( Boolean ) as string [ ]
156
- ) ,
157
- ] ;
152
+ const apiItems = items . filter ( isApiItem ) ;
153
+
154
+ const tags = uniq (
155
+ apiItems
156
+ . flatMap ( ( item ) => item . api . tags )
157
+ . filter ( ( item ) : item is string => ! ! item )
158
+ ) ;
159
+
160
+ function createLink ( item : ApiItem ) {
161
+ return {
162
+ type : "link" as const ,
163
+ label : item . title ,
164
+ href : item . permalink ,
165
+ docId : item . id ,
166
+ className : clsx (
167
+ {
168
+ "menu__list-item--deprecated" : item . api . deprecated ,
169
+ "api-method" : ! ! item . api . method ,
170
+ } ,
171
+ item . api . method
172
+ ) ,
173
+ } ;
174
+ }
158
175
159
176
const tagged = tags
160
177
. map ( ( tag ) => {
161
178
return {
162
179
type : "category" as const ,
163
180
label : tag ,
164
- collapsible : sidebarCollapsible ,
165
- collapsed : sidebarCollapsed ,
166
- items : items
167
- . filter ( ( item ) : item is ApiPageMetadata => {
168
- if ( isInfoItem ( item ) ) {
169
- return false ;
170
- }
171
- return ! ! item . api . tags ?. includes ( tag ) ;
172
- } )
173
- . map ( ( item ) => {
174
- return {
175
- type : "link" as const ,
176
- label : item . title ,
177
- href : item . permalink ,
178
- docId : item . id ,
179
- className : clsx ( {
180
- "menu__list-item--deprecated" : item . api . deprecated ,
181
- "api-method" : ! ! item . api . method ,
182
- [ item . api . method ] : ! ! item . api . method ,
183
- } ) ,
184
- } ;
185
- } ) ,
181
+ collapsible : options . sidebarCollapsible ,
182
+ collapsed : options . sidebarCollapsed ,
183
+ items : apiItems
184
+ . filter ( ( item ) => ! ! item . api . tags ?. includes ( tag ) )
185
+ . map ( createLink ) ,
186
186
} ;
187
187
} )
188
- . filter ( ( item ) => item . items . length > 0 ) ;
188
+ . filter ( ( item ) => item . items . length > 0 ) ; // Filter out any categories with no items.
189
189
190
190
const untagged = [
191
191
{
192
192
type : "category" as const ,
193
193
label : "API" ,
194
- collapsible : sidebarCollapsible ,
195
- collapsed : sidebarCollapsed ,
196
- items : items
197
- . filter ( ( item ) : item is ApiPageMetadata => {
198
- // Filter out info pages and pages with tags
199
- if ( isInfoItem ( item ) ) {
200
- return false ;
201
- }
202
- if ( item . api . tags === undefined || item . api . tags . length === 0 ) {
203
- // no tags
204
- return true ;
205
- }
206
- return false ;
207
- } )
208
- . map ( ( item ) => {
209
- return {
210
- type : "link" as const ,
211
- label : item . title ,
212
- href : item . permalink ,
213
- docId : item . id ,
214
- className : clsx ( {
215
- "menu__list-item--deprecated" : item . api . deprecated ,
216
- "api-method" : ! ! item . api . method ,
217
- [ item . api . method ] : ! ! item . api . method ,
218
- } ) ,
219
- } ;
220
- } ) ,
194
+ collapsible : options . sidebarCollapsible ,
195
+ collapsed : options . sidebarCollapsed ,
196
+ items : apiItems
197
+ . filter ( ( { api } ) => api . tags === undefined || api . tags . length === 0 )
198
+ . map ( createLink ) ,
221
199
} ,
222
200
] ;
223
201
224
202
return [ ...intros , ...tagged , ...untagged ] ;
225
203
}
226
204
227
- export const CategoryMetadataFilenameBase = "_category_" ;
228
-
205
+ /**
206
+ * Taken from: https://github.com/facebook/docusaurus/blob/main/packages/docusaurus-plugin-content-docs/src/sidebars/generator.ts
207
+ */
229
208
async function readCategoryMetadataFile (
230
209
categoryDirPath : string
231
- ) : Promise < any | null > {
232
- async function tryReadFile ( filePath : string ) : Promise < any > {
210
+ ) : Promise < CategoryMetadataFile | null > {
211
+ async function tryReadFile ( filePath : string ) : Promise < CategoryMetadataFile > {
233
212
const contentString = await fs . readFile ( filePath , { encoding : "utf8" } ) ;
234
213
const unsafeContent = Yaml . load ( contentString ) ;
235
214
try {
236
215
return validateCategoryMetadataFile ( unsafeContent ) ;
237
216
} catch ( e ) {
238
217
console . error (
239
218
chalk . red (
240
- `The docs sidebar category metadata file looks invalid!\nPath: ${ filePath } `
219
+ `The docs sidebar category metadata file path= ${ filePath } looks invalid! `
241
220
)
242
221
) ;
243
222
throw e ;
0 commit comments