Skip to content

Commit caa0e34

Browse files
authored
Sidebar generation refactor (#111)
1 parent 80a2674 commit caa0e34

File tree

3 files changed

+145
-164
lines changed

3 files changed

+145
-164
lines changed

packages/docusaurus-plugin-openapi/src/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { Configuration } from "webpack";
2525

2626
import { createApiPageMD, createInfoPageMD } from "./markdown";
2727
import { readOpenapiFiles, processOpenapiFiles } from "./openapi";
28-
import { generateSidebars } from "./sidebars";
28+
import { generateSidebar } from "./sidebars";
2929
import { PluginOptions, LoadedContent } from "./types";
3030

3131
export default function pluginOpenAPI(
@@ -85,7 +85,8 @@ export default function pluginOpenAPI(
8585

8686
const sidebarName = `openapi-sidebar-${pluginId}`;
8787

88-
const sidebar = await generateSidebars(loadedApi, {
88+
const sidebar = await generateSidebar(loadedApi, {
89+
contentPath,
8990
sidebarCollapsible,
9091
sidebarCollapsed,
9192
});

packages/docusaurus-plugin-openapi/src/sidebars/index.ts

Lines changed: 133 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -7,42 +7,37 @@
77

88
import path from "path";
99

10+
import {
11+
CategoryMetadataFile,
12+
CategoryMetadataFilenameBase,
13+
} from "@docusaurus/plugin-content-docs/lib/sidebars/generator";
1014
import { validateCategoryMetadataFile } from "@docusaurus/plugin-content-docs/lib/sidebars/validation";
1115
import { posixPath } from "@docusaurus/utils";
1216
import chalk from "chalk";
1317
import clsx from "clsx";
1418
import fs from "fs-extra";
1519
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";
1928
import { ApiPageMetadata } from "../types";
2029

2130
interface Options {
31+
contentPath: string;
2232
sidebarCollapsible: boolean;
2333
sidebarCollapsed: boolean;
2434
}
2535

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";
3737

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"]>;
4641
};
4742

4843
type Item = InfoItem | ApiItem;
@@ -55,85 +50,96 @@ function isInfoItem(item: Item): item is InfoItem {
5550
return item.type === "info";
5651
}
5752

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(
5964
items: Item[],
6065
options: Options
6166
): 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+
}
9977

100-
const dirs = childSection.sourceDirName.split("/");
78+
const { sourceDirName, source } = items[0];
10179

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

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({
11691
type: "category" as const,
11792
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;
124116
}
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;
126128
}
127-
root.push(childSection);
128129
}
129130

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;
131137
}
132138

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 {
137143
const intros = items.filter(isInfoItem).map((item) => {
138144
return {
139145
type: "link" as const,
@@ -143,101 +149,74 @@ function groupByTags(
143149
};
144150
});
145151

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+
}
158175

159176
const tagged = tags
160177
.map((tag) => {
161178
return {
162179
type: "category" as const,
163180
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),
186186
};
187187
})
188-
.filter((item) => item.items.length > 0);
188+
.filter((item) => item.items.length > 0); // Filter out any categories with no items.
189189

190190
const untagged = [
191191
{
192192
type: "category" as const,
193193
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),
221199
},
222200
];
223201

224202
return [...intros, ...tagged, ...untagged];
225203
}
226204

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+
*/
229208
async function readCategoryMetadataFile(
230209
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> {
233212
const contentString = await fs.readFile(filePath, { encoding: "utf8" });
234213
const unsafeContent = Yaml.load(contentString);
235214
try {
236215
return validateCategoryMetadataFile(unsafeContent);
237216
} catch (e) {
238217
console.error(
239218
chalk.red(
240-
`The docs sidebar category metadata file looks invalid!\nPath: ${filePath}`
219+
`The docs sidebar category metadata file path=${filePath} looks invalid!`
241220
)
242221
);
243222
throw e;

0 commit comments

Comments
 (0)