From 59c8ffb19db2ce468c6a859cbfeec68e969926a9 Mon Sep 17 00:00:00 2001 From: Paul Gottschling Date: Tue, 16 Jul 2024 11:05:29 -0400 Subject: [PATCH] Generate sidebar entries at an arbitrary depth Currently, the code that generates the navigation sidebar from a directory tree stops at the second level of a given top-level section. However, some sections include three levels of content. This change edits the sidebar generator so it works recursively. Also fix an issue with the `DocsNavigationItems` component that prevents the docs site from highlighting sidebar entries past two levels of depth. The component treats a sidebar subsection as "active" if one of its entries is equivalent to the current page path. But if the current page path is a grandchild of a sidebar subsection, this means that the component hides the grandchild, since none of the children of the subsection is equivalent to the current page. This change determines that a sidebar subsection is "active" if the selected path _includes_ the subsection path. --- .storybook/main.ts | 5 +- layouts/DocsPage/Navigation.stories.tsx | 71 ++++++++++++++ layouts/DocsPage/Navigation.tsx | 26 +++++- server/pages-helpers.ts | 69 ++++++-------- uvu-tests/config-docs.test.ts | 119 +++++++++++++++++------- 5 files changed, 206 insertions(+), 84 deletions(-) create mode 100644 layouts/DocsPage/Navigation.stories.tsx diff --git a/.storybook/main.ts b/.storybook/main.ts index 290946945a..472f128627 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,7 +1,10 @@ import type { StorybookConfig } from "@storybook/nextjs"; const config: StorybookConfig = { - stories: ["../components/**/*.stories.@(js|jsx|ts|tsx)"], + stories: [ + "../components/**/*.stories.@(js|jsx|ts|tsx)", + "../layouts/**/*.stories.@(js|jsx|ts|tsx)", + ], addons: ["@storybook/addon-interactions", "@storybook/addon-viewport"], framework: { name: "@storybook/nextjs", diff --git a/layouts/DocsPage/Navigation.stories.tsx b/layouts/DocsPage/Navigation.stories.tsx new file mode 100644 index 0000000000..1172bfe49b --- /dev/null +++ b/layouts/DocsPage/Navigation.stories.tsx @@ -0,0 +1,71 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { userEvent, within } from "@storybook/testing-library"; +import { expect } from "@storybook/jest"; +import { default as DocNavigation } from "layouts/DocsPage/Navigation"; + +import { TabItem } from "./TabItem"; +import { Tabs } from "./Tabs"; + +export const NavigationFourLevels = () => { + const data = [ + { + icon: "wrench", + title: "Enroll Resources", + entries: [ + { + title: "Machine ID", + slug: "/enroll-resources/machine-id/", + entries: [ + { + title: "Deploy Machine ID", + slug: "/enroll-resources/machine-id/deployment/", + entries: [ + { + title: "Deploy Machine ID on AWS", + slug: "/enroll-resources/machine-id/deployment/aws/", + }, + ], + }, + ], + }, + ], + }, + ]; + + return ( + { + return "/enroll-resources/machine-id/deployment/aws/"; + }} + > + ); +}; + +const meta: Meta = { + title: "layouts/DocNavigation", + component: NavigationFourLevels, +}; +export default meta; +type Story = StoryObj; + +// export const InitialStateTab: Story = { +// play: async ({ canvasElement, step }) => { +// const canvas = within(canvasElement); +// await step("Should be the text for the selected option", async () => { +// expect( +// await canvas.findByText( +// "Instructions for installing release using shell commands." +// ) +// ).toBeTruthy(); +// }); +// await step( +// "Selected option should be disabled for re-selection", +// async () => { +// expect(canvas.getByText("Shell")).toBeDisabled(); +// } +// ); +// }, +// }; diff --git a/layouts/DocsPage/Navigation.tsx b/layouts/DocsPage/Navigation.tsx index 07ad63b558..6d7a9f835d 100644 --- a/layouts/DocsPage/Navigation.tsx +++ b/layouts/DocsPage/Navigation.tsx @@ -24,22 +24,27 @@ const SCOPE_DICTIONARY: Record = { interface DocsNavigationItemsProps { entries: NavigationItem[]; onClick: () => void; + currentPath: string; } const DocsNavigationItems = ({ entries, onClick, + currentPath, }: DocsNavigationItemsProps) => { - const docPath = useCurrentHref().split(SCOPELESS_HREF_REGEX)[0]; + const docPath = currentPath.split(SCOPELESS_HREF_REGEX)[0]; const { getVersionAgnosticRoute } = useVersionAgnosticPages(); + console.log("entries:", entries); + return ( <> {!!entries.length && entries.map((entry) => { const selected = entry.slug === docPath; const active = - selected || entry.entries?.some((entry) => entry.slug === docPath); + selected || + entry.entries?.some((entry) => docPath.includes(entry.slug)); return (
  • @@ -62,6 +67,7 @@ const DocsNavigationItems = ({ )} @@ -77,6 +83,7 @@ interface DocNavigationCategoryProps extends NavigationCategory { opened: boolean; onToggleOpened: (value: number) => void; onClick: () => void; + currentPath: string; } const DocNavigationCategory = ({ @@ -87,6 +94,7 @@ const DocNavigationCategory = ({ icon, title, entries, + currentPath, }: DocNavigationCategoryProps) => { const toggleOpened = useCallback( () => onToggleOpened(opened ? null : id), @@ -105,7 +113,11 @@ const DocNavigationCategory = ({ {opened && (
      - +
    )} @@ -134,14 +146,19 @@ interface DocNavigationProps { section?: boolean; currentVersion?: string; data: NavigationCategory[]; + currentPathGetter: () => string; } const DocNavigation = ({ data, section, currentVersion, + currentPathGetter, }: DocNavigationProps) => { - const route = useCurrentHref(); + if (!currentPathGetter) { + currentPathGetter = useCurrentHref; + } + const route = currentPathGetter(); const [openedId, setOpenedId] = useState( getCurrentCategoryIndex(data, route) @@ -171,6 +188,7 @@ const DocNavigation = ({ opened={index === openedId} onToggleOpened={setOpenedId} onClick={toggleMenu} + currentPath={route} {...props} />
  • diff --git a/server/pages-helpers.ts b/server/pages-helpers.ts index 4dfcb43e20..88f68f6f6e 100644 --- a/server/pages-helpers.ts +++ b/server/pages-helpers.ts @@ -62,6 +62,8 @@ export const getPageInfo = ( return result; }; +// getEntryForPath returns a navigation item for the file at filePath in the +// given filesystem. const getEntryForPath = (fs, filePath) => { const txt = fs.readFileSync(filePath, "utf8"); const { data } = matter(txt); @@ -108,11 +110,13 @@ const categoryPagePathForDir = (fs, dirPath) => { ); }; -export const generateNavPaths = (fs, dirPath) => { +export const navEntriesForDir = (fs, dirPath) => { const firstLvl = fs.readdirSync(dirPath, "utf8"); let result = []; let firstLvlFiles = new Set(); let firstLvlDirs = new Set(); + + // Sort the contents of dirPath into files and directoreis. firstLvl.forEach((p) => { const fullPath = join(dirPath, p); const info = fs.statSync(fullPath); @@ -120,11 +124,22 @@ export const generateNavPaths = (fs, dirPath) => { firstLvlDirs.add(fullPath); return; } + const fileName = parse(fullPath).name; + const dirName = parse(dirPath).name; + + // This is a category page for the containing directory. We would have + // already handled this in the previous iteration. The first iteration + // does not require a category page. + if (fileName == dirName) { + return; + } + firstLvlFiles.add(fullPath); }); // Map category pages to the directories they introduce so we can can add a - // sidebar entry for the category page, then traverse the directory. + // sidebar entry for each category page, then traverse each directory to add + // further sidebar pages. let sectionIntros = new Map(); firstLvlDirs.forEach((d: string) => { sectionIntros.set(categoryPagePathForDir(fs, d), d); @@ -145,6 +160,9 @@ export const generateNavPaths = (fs, dirPath) => { result.push(getEntryForPath(fs, f)); }); + // Add a category page for each section intro, then traverse the contents of + // the directory that the category page introduces, adding the contents to + // entries. sectionIntros.forEach((dirPath, categoryPagePath) => { const { slug, title } = getEntryForPath(fs, categoryPagePath); const section = { @@ -152,49 +170,14 @@ export const generateNavPaths = (fs, dirPath) => { slug: slug, entries: [], }; - const secondLvl = new Set(fs.readdirSync(dirPath, "utf8")); - - // Find all second-level category pages first so we don't - // repeat them in the sidebar. - secondLvl.forEach((f2: string) => { - let fullPath2 = join(dirPath, f2); - const stat = fs.statSync(fullPath2); - - // List category pages on the second level, but not their contents. - if (!stat.isDirectory()) { - return; - } - const catPath = categoryPagePathForDir(fs, fullPath2); - fullPath2 = catPath; - secondLvl.delete(f2); - - // Delete the category page from the set so we don't add it again - // when we add individual files. - secondLvl.delete(parse(catPath).base); - section.entries.push(getEntryForPath(fs, fullPath2)); - }); - - secondLvl.forEach((f2: string) => { - // Only add entries for MDX files here - if (!f2.endsWith(".mdx")) { - return; - } - - let fullPath2 = join(dirPath, f2); - - // This is a first-level category page that happens to exist on the second - // level. - if (sectionIntros.has(fullPath2)) { - return; - } - - const stat = fs.statSync(fullPath2); - section.entries.push(getEntryForPath(fs, fullPath2)); - }); - - section.entries.sort(sortByTitle); + + section.entries = navEntriesForDir(fs, dirPath); result.push(section); }); result.sort(sortByTitle); return result; }; + +export const generateNavPaths = (fs, dirPath) => { + return navEntriesForDir(fs, dirPath); +}; diff --git a/uvu-tests/config-docs.test.ts b/uvu-tests/config-docs.test.ts index bdef7dc9a4..b7272875d6 100644 --- a/uvu-tests/config-docs.test.ts +++ b/uvu-tests/config-docs.test.ts @@ -289,54 +289,57 @@ title: MySQL Guide } ); -Suite( - "generateNavPaths shows third-level category pages on the sidebar", - () => { - const files = { - "/docs/pages/database-access/guides/guides.mdx": `--- +Suite("generateNavPaths shows third-level pages on the sidebar", () => { + const files = { + "/docs/pages/database-access/guides/guides.mdx": `--- title: Database Access Guides ---`, - "/docs/pages/database-access/guides/postgres.mdx": `--- + "/docs/pages/database-access/guides/postgres.mdx": `--- title: Postgres Guide ---`, - "/docs/pages/database-access/guides/mysql.mdx": `--- + "/docs/pages/database-access/guides/mysql.mdx": `--- title: MySQL Guide ---`, - "/docs/pages/database-access/guides/rbac/rbac.mdx": `--- + "/docs/pages/database-access/guides/rbac/rbac.mdx": `--- title: Database Access RBAC ---`, - "/docs/pages/database-access/guides/rbac/get-started.mdx": `--- + "/docs/pages/database-access/guides/rbac/get-started.mdx": `--- title: Get Started with DB RBAC ---`, - }; + }; - const expected = [ - { - title: "Database Access Guides", - slug: "/database-access/guides/guides/", - entries: [ - { - title: "Database Access RBAC", - slug: "/database-access/guides/rbac/rbac/", - }, - { - title: "MySQL Guide", - slug: "/database-access/guides/mysql/", - }, - { - title: "Postgres Guide", - slug: "/database-access/guides/postgres/", - }, - ], - }, - ]; + const expected = [ + { + title: "Database Access Guides", + slug: "/database-access/guides/guides/", + entries: [ + { + title: "Database Access RBAC", + slug: "/database-access/guides/rbac/rbac/", + entries: [ + { + title: "Get Started with DB RBAC", + slug: "/database-access/guides/rbac/get-started/", + }, + ], + }, + { + title: "MySQL Guide", + slug: "/database-access/guides/mysql/", + }, + { + title: "Postgres Guide", + slug: "/database-access/guides/postgres/", + }, + ], + }, + ]; - const vol = Volume.fromJSON(files); - const fs = createFsFromVolume(vol); - const actual = generateNavPaths(fs, "/docs/pages/database-access"); - assert.equal(actual, expected); - } -); + const vol = Volume.fromJSON(files); + const fs = createFsFromVolume(vol); + const actual = generateNavPaths(fs, "/docs/pages/database-access"); + assert.equal(actual, expected); +}); Suite( "allows category pages in the same directory as the associated subdirectory", @@ -367,6 +370,12 @@ title: Get Started with DB RBAC { title: "Database Access RBAC", slug: "/database-access/guides/rbac/", + entries: [ + { + title: "Get Started with DB RBAC", + slug: "/database-access/guides/rbac/get-started/", + }, + ], }, { title: "MySQL Guide", @@ -387,4 +396,42 @@ title: Get Started with DB RBAC } ); +Suite("generates four levels of the sidebar", () => { + const files = { + "/docs/pages/database-access/guides/guides.mdx": `--- +title: Database Access Guides +---`, + "/docs/pages/database-access/guides/deployment/kubernetes.mdx": `--- +title: Database Access Kubernetes Deployment +---`, + "/docs/pages/database-access/guides/deployment/deployment.mdx": `--- +title: Database Access Deployment Guides +---`, + }; + + const expected = [ + { + title: "Database Access Guides", + slug: "/database-access/guides/guides/", + entries: [ + { + title: "Database Access Deployment Guides", + slug: "/database-access/guides/deployment/deployment/", + entries: [ + { + title: "Database Access Kubernetes Deployment", + slug: "/database-access/guides/deployment/kubernetes/", + }, + ], + }, + ], + }, + ]; + + const vol = Volume.fromJSON(files); + const fs = createFsFromVolume(vol); + let actual = generateNavPaths(fs, "/docs/pages/database-access"); + assert.equal(actual, expected); +}); + Suite.run();