Skip to content

Commit 63bbfb2

Browse files
authored
Adapt DocCardList for the Teleport docs site (#86)
Partially addresses #29 Swizzle the Docusaurus-native `DocCardList` component and adapt it to suit the Teleport docs site. Since we prefer a more text-oriented approach to components, edit `DocCardList` to return a plain `ul`, rather than tiles. Since the `DocCardList` component is a Docusaurus-native alternative to our `remark-toc` plugin, edit `remark-toc` to return a `DocCardList` instead of querying the filesystem to generate a table of contents. Once we replace all `(!toc!)` expressions with `<DocCardList />` elements, we can remove `remark-toc` entirely. Also make `DocCardList` an MDX component so docs authors don't need to add `import` statements to the top of a docs page. To test one function used in `DocCardList`, reorganize unit testing to use Jest for some frontend code that doesn't depend on React or browser APIs. - Rename the Jest config to acknowledge that it's not just for server code - Edit `yarn tests` to run Jest tests for component code.
1 parent e6d5399 commit 63bbfb2

File tree

10 files changed

+135
-181
lines changed

10 files changed

+135
-181
lines changed
File renamed without changes.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"storybook:build": "storybook build",
2828
"storybook:test-ci": "npx concurrently --kill-others --success first --names \"STORYBOOK,TESTS\" -c \"magenta,blue\" \"npx http-server -a 127.0.0.1 --silent storybook-static --port 6006\" \"npx wait-on tcp:127.0.0.1:6006 && yarn storybook:test --url http://127.0.0.1:6006/storybook-static \"",
2929
"storybook:test": "test-storybook",
30-
"test": "NODE_OPTIONS=\"$NODE_OPTIONS --trace-warnings --experimental-vm-modules\" jest --config ./jest.server.config.mjs server/*.test.ts"
30+
"test": "NODE_OPTIONS=\"$NODE_OPTIONS --trace-warnings --experimental-vm-modules\" jest --config ./jest.config.mjs server/*.test.ts src/theme/**/*.test.ts"
3131
},
3232
"lint-staged": {
3333
"*.{js,jsx,ts,tsx}": [

server/fixtures/toc/expected.mdx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,4 @@
22

33
Here is an intro.
44

5-
* [Protect Databases with Teleport](database-access.mdx): Guides to protecting databases with Teleport.
6-
* [Protect MySQL with Teleport](mysql.mdx): How to enroll your MySQL database with Teleport
7-
* [Protect Postgres with Teleport](postgres.mdx): How to enroll Postgres with your Teleport cluster
5+
<DocCardList />

server/remark-toc.test.ts

Lines changed: 1 addition & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, test } from "@jest/globals";
22
import { Volume, createFsFromVolume } from "memfs";
3-
import { default as remarkTOC, getTOC } from "../server/remark-toc";
3+
import { default as remarkTOC } from "../server/remark-toc";
44
import { readFileSync } from "fs";
55
import { resolve } from "path";
66
import { Compatible, VFile, VFileOptions } from "vfile";
@@ -52,104 +52,6 @@ description: "Protecting App 2 with Teleport"
5252
---`,
5353
};
5454

55-
test("getTOC with one link to a directory", () => {
56-
const expected = `- [Application Access](application-access/application-access.mdx): Guides related to Application Access`;
57-
58-
const vol = Volume.fromJSON({
59-
"/docs/docs.mdx": `---
60-
title: Documentation Home
61-
description: Guides for setting up the product.
62-
---
63-
64-
Guides for setting up the product.
65-
66-
`,
67-
"/docs/application-access/application-access.mdx": `---
68-
title: "Application Access"
69-
description: "Guides related to Application Access"
70-
---
71-
72-
`,
73-
"/docs/application-access/page1.mdx": `---
74-
title: "Application Access Page 1"
75-
description: "Protecting App 1 with Teleport"
76-
---`,
77-
"/docs/application-access/page2.mdx": `---
78-
title: "Application Access Page 2"
79-
description: "Protecting App 2 with Teleport"
80-
---`,
81-
});
82-
const fs = createFsFromVolume(vol);
83-
const actual = getTOC("/docs/docs.mdx", fs);
84-
expect(actual.result).toBe(expected);
85-
});
86-
87-
test("getTOC with multiple links to directories", () => {
88-
const expected = `- [Application Access](application-access/application-access.mdx): Guides related to Application Access
89-
- [Database Access](database-access/database-access.mdx): Guides related to Database Access.`;
90-
91-
const vol = Volume.fromJSON(testFilesTwoSections);
92-
const fs = createFsFromVolume(vol);
93-
const actual = getTOC("/docs/docs.mdx", fs);
94-
expect(actual.result).toBe(expected);
95-
});
96-
97-
test("getTOC orders sections correctly", () => {
98-
const expected = `- [API Usage](api.mdx): Using the API.
99-
- [Application Access](application-access/application-access.mdx): Guides related to Application Access
100-
- [Desktop Access](desktop-access/desktop-access.mdx): Guides related to Desktop Access
101-
- [Initial Setup](initial-setup.mdx): How to set up the product for the first time.
102-
- [Kubernetes](kubernetes.mdx): A guide related to Kubernetes.`;
103-
104-
const vol = Volume.fromJSON({
105-
"/docs/docs.mdx": `---
106-
title: Documentation Home
107-
description: Guides to setting up the product.
108-
---
109-
110-
Guides to setting up the product.
111-
112-
`,
113-
"/docs/desktop-access/desktop-access.mdx": `---
114-
title: "Desktop Access"
115-
description: "Guides related to Desktop Access"
116-
---
117-
118-
`,
119-
120-
"/docs/application-access/application-access.mdx": `---
121-
title: "Application Access"
122-
description: "Guides related to Application Access"
123-
---
124-
125-
`,
126-
"/docs/desktop-access/get-started.mdx": `---
127-
title: "Get Started"
128-
description: "Get started with desktop access."
129-
---`,
130-
"/docs/application-access/page1.mdx": `---
131-
title: "Application Access Page 1"
132-
description: "Protecting App 1 with Teleport"
133-
---`,
134-
"/docs/kubernetes.mdx": `---
135-
title: "Kubernetes"
136-
description: "A guide related to Kubernetes."
137-
---`,
138-
139-
"/docs/initial-setup.mdx": `---
140-
title: "Initial Setup"
141-
description: "How to set up the product for the first time."
142-
---`,
143-
"/docs/api.mdx": `---
144-
title: "API Usage"
145-
description: "Using the API."
146-
---`,
147-
});
148-
const fs = createFsFromVolume(vol);
149-
const actual = getTOC("/docs/docs.mdx", fs);
150-
expect(actual.result).toBe(expected);
151-
});
152-
15355
const transformer = (vfileOptions: VFileOptions) => {
15456
const file: VFile = new VFile(vfileOptions);
15557

server/remark-toc.ts

Lines changed: 7 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -8,76 +8,6 @@ import type { VFile } from "vfile";
88
import type { Root, Content } from "mdast";
99
import type { Transformer } from "unified";
1010

11-
// relativePathToFile takes a filepath and returns a path we can use in links
12-
// to the file in a table of contents page. The link path is a relative path
13-
// to the directory where we are placing the table of contents page.
14-
// @param root {string} - the directory path to the table of contents page.
15-
// @param filepath {string} - the path from which to generate a link path.
16-
const relativePathToFile = (root: string, filepath: string) => {
17-
// Return the filepath without the first segment, removing the first
18-
// slash. This is because the TOC file we are generating is located at
19-
// root.
20-
return filepath.slice(root.length).replace(/^\//, "");
21-
};
22-
23-
// getTOC generates a list of links to all files in the same directory as
24-
// filePath except for filePath. The return value is an object with two
25-
// properties:
26-
// - result: a string containing the resulting list of links.
27-
// - error: an error message encountered during processing
28-
export const getTOC = (filePath: string, fs: any = nodeFS) => {
29-
const dirPath = path.dirname(filePath);
30-
if (!fs.existsSync(dirPath)) {
31-
return {
32-
error: `Cannot generate a table of contents for nonexistent directory at ${dirPath}`,
33-
};
34-
}
35-
36-
const { name } = path.parse(filePath);
37-
38-
const files = fs.readdirSync(dirPath, "utf8");
39-
let mdxFiles = new Set();
40-
const dirs = files.reduce((accum, current) => {
41-
// Don't add a TOC entry for the current file.
42-
if (name == path.parse(current).name) {
43-
return accum;
44-
}
45-
const stats = fs.statSync(path.join(dirPath, current));
46-
if (!stats.isDirectory() && current.endsWith(".mdx")) {
47-
mdxFiles.add(path.join(dirPath, current));
48-
return accum;
49-
}
50-
accum.add(path.join(dirPath, current));
51-
return accum;
52-
}, new Set());
53-
54-
// Add rows to the menu page for non-menu pages.
55-
const entries = [];
56-
mdxFiles.forEach((f: string, idx: number) => {
57-
const text = fs.readFileSync(f, "utf8");
58-
let relPath = relativePathToFile(dirPath, f);
59-
const { data } = matter(text);
60-
entries.push(`- [${data.title}](${relPath}): ${data.description}`);
61-
});
62-
63-
// Add rows to the menu page for first-level child menu pages
64-
dirs.forEach((f: string, idx: number) => {
65-
const menuPath = path.join(f, path.parse(f).base + ".mdx");
66-
if (!fs.existsSync(menuPath)) {
67-
return {
68-
error: `there must be a page called ${menuPath} that introduces ${f}`,
69-
};
70-
}
71-
const text = fs.readFileSync(menuPath, "utf8");
72-
let relPath = relativePathToFile(dirPath, menuPath);
73-
const { data } = matter(text);
74-
75-
entries.push(`- [${data.title}](${relPath}): ${data.description}`);
76-
});
77-
entries.sort();
78-
return { result: entries.join("\n") };
79-
};
80-
8111
const tocRegexpPattern = "^\\(!toc!\\)$";
8212

8313
// remarkTOC replaces (!toc!) syntax in a page with a list of docs pages at a
@@ -104,17 +34,17 @@ export default function remarkTOC(): Transformer {
10434
return;
10535
}
10636

107-
const { result, error } = getTOC(vfile.path);
108-
if (!!error) {
109-
vfile.message(error, node);
110-
return;
111-
}
112-
const tree = fromMarkdown(result, {});
37+
const tree = {
38+
type: "mdxJsxFlowElement",
39+
name: "DocCardList",
40+
attributes: [],
41+
children: [],
42+
};
11343

11444
const grandParent = ancestors[ancestors.length - 2] as Parent;
11545
const parentIndex = grandParent.children.indexOf(parent);
11646

117-
grandParent.children.splice(parentIndex, 1, ...(tree as Root).children);
47+
grandParent.children.splice(parentIndex, 1, tree);
11848
});
11949
};
12050
}

server/sidebar-order.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import type {
33
SidebarItemDoc,
44
NormalizedSidebarItemCategory,
55
} from "@docusaurus/plugin-content-docs/src/sidebars/types.ts";
6-
import type { PropVersionDoc } from "@docusaurus/plugin-content-docs";
76

87
export interface docPage {
98
title: string;
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, expect, test } from "@jest/globals";
2+
import { categoryHrefToDocID } from "./href-to-id";
3+
4+
describe("categoryHrefToDocID", () => {
5+
interface testCase {
6+
description: string;
7+
href: string;
8+
expectedID: string;
9+
}
10+
11+
const testCases: Array<testCase> = [
12+
{
13+
description: "category index page in versioned path",
14+
href: "/ver/15.x/reference/agent-services/",
15+
expectedID: "reference/agent-services/agent-services",
16+
},
17+
{
18+
description: "category index page in unversioned path",
19+
href: "/reference/agent-services/",
20+
expectedID: "reference/agent-services/agent-services",
21+
},
22+
{
23+
description: "docs URL segment in versioned path",
24+
href: "/docs/ver/15.x/admin-guides/access-controls/guides/joining-sessions",
25+
expectedID: "admin-guides/access-controls/guides/joining-sessions/joining-sessions",
26+
},
27+
{
28+
description: "docs URL segment in unversioned path",
29+
href: "/docs/admin-guides/access-controls/guides/joining-sessions",
30+
expectedID: "admin-guides/access-controls/guides/joining-sessions/joining-sessions",
31+
},
32+
];
33+
34+
test.each(testCases)("$description", (c) => {
35+
expect(categoryHrefToDocID(c.href)).toEqual(c.expectedID);
36+
});
37+
});

src/theme/DocCardList/href-to-id.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// categoryHrefoToDocID returns the Docusaurus page ID that corresponds to the
2+
// given href, which points to a category index page. Category pages do not have
3+
// IDs in the items prop, so we generate a page ID based on the assumption that
4+
// category page slugs are the same as their containing directory names.
5+
export function categoryHrefToDocID(href: string): string {
6+
// Remove initial path segments that are not involved in generating the page
7+
// ID, such as "/docs/" and "/ver/15.x/".
8+
let idPrefix = href.replace(new RegExp(`^/(docs/)?(ver/[0-9]+\\.x/)?`), "");
9+
// Ensure that trailing slashes are trimmed so we can uniformly add the slug
10+
// segment.
11+
idPrefix = idPrefix.replace(new RegExp(`/$`), "");
12+
const slugRE = new RegExp(`/([^/]+)/?$`);
13+
const slug = slugRE.exec(href);
14+
if (!slug || slug.length < 2) {
15+
throw new Error(`could not identify a category page ID for href ${href}`);
16+
}
17+
return idPrefix + "/" + slug[1];
18+
}

src/theme/DocCardList/index.tsx

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import React from "react";
2+
import {
3+
useCurrentSidebarCategory,
4+
filterDocCardListItems,
5+
useDocById,
6+
} from "@docusaurus/plugin-content-docs/client";
7+
import DocCard from "@theme/DocCard";
8+
import type { Props } from "@theme/DocCardList";
9+
import type {
10+
PropVersionDoc,
11+
PropSidebarItemCategory,
12+
} from "@docusaurus/plugin-content-docs";
13+
import { categoryHrefToDocID } from "./href-to-id";
14+
15+
function DocCardListForCurrentSidebarCategory({ className }: Props) {
16+
let category: PropSidebarItemCategory;
17+
// DocCardList only works if the current page has a sidebar entry, but the
18+
// error Docusaurus currently throws is difficult to understand. Throw an
19+
// error with a more explicit message.
20+
try {
21+
category = useCurrentSidebarCategory();
22+
} catch ({ message }) {
23+
if (!message.includes("Unexpected: cant find current sidebar in context")) {
24+
throw new Error(message);
25+
}
26+
throw new Error(
27+
"The current page does not have a corresponding sidebar entry, so it is not possible to use DocCardList. Make sure that the page is represented on the sidebar."
28+
);
29+
}
30+
return <DocCardList items={category.items} className={className} />;
31+
}
32+
33+
export default function DocCardList(props: Props): JSX.Element {
34+
const { items, className } = props;
35+
if (!items) {
36+
return <DocCardListForCurrentSidebarCategory {...props} />;
37+
}
38+
const filteredItems = filterDocCardListItems(items).map((item) => {
39+
const doc = useDocById(item.docId);
40+
41+
if (item.type == "link") {
42+
return {
43+
href: item.href,
44+
label: item.label,
45+
description: doc?.description,
46+
};
47+
}
48+
if (item.type == "category") {
49+
const indexPage = useDocById(categoryHrefToDocID(item.href) ?? undefined);
50+
51+
return {
52+
href: item.href,
53+
label: item.label + " (section)",
54+
description: indexPage?.description,
55+
};
56+
}
57+
});
58+
59+
return (
60+
<ul className={className}>
61+
{filteredItems.map((item, index) => (
62+
<li key={index}>
63+
<a href={item.href}>{item.label}</a>: {item.description}
64+
</li>
65+
))}
66+
</ul>
67+
);
68+
}

src/theme/MDXComponents/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import OriginalMDXComponents from "@theme-original/MDXComponents";
22
import Tabs from "@theme/Tabs";
33
import TabItem from "@theme/TabItem";
4+
import DocCardList from "@theme/DocCardList";
45
import Admonition from "@theme/Admonition";
56
import React, { type ComponentProps } from "react";
67
import Head from "@docusaurus/Head";
@@ -21,6 +22,7 @@ import type { MDXComponentsObject } from "@theme/MDXComponents";
2122
const MDXComponents: MDXComponentsObject = {
2223
...OriginalMDXComponents,
2324
Details: MDXDetails,
25+
DocCardList: DocCardList,
2426
Head,
2527
TabItem,
2628
Tabs,

0 commit comments

Comments
 (0)