From 1c0d2442c3da01a045d1c7f3f3c2d6a0cc4893c1 Mon Sep 17 00:00:00 2001 From: Paul Gottschling Date: Tue, 16 Jul 2024 11:05:29 -0400 Subject: [PATCH] Allow four levels of sidebar entries 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 _starts with_ the subsection path. Also edit the CSS padding of navigation links to depend on the current level of the navigation menu. This allows for indentation of submenu links beyond the second level. --- .storybook/main.ts | 5 +- layouts/DocsPage/Navigation.module.css | 15 ++- layouts/DocsPage/Navigation.stories.tsx | 49 ++++++++++ layouts/DocsPage/Navigation.tsx | 34 ++++++- server/pages-helpers.ts | 69 ++++++-------- server/remark-toc.ts | 2 +- uvu-tests/config-docs.test.ts | 119 +++++++++++++++++------- 7 files changed, 205 insertions(+), 88 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.module.css b/layouts/DocsPage/Navigation.module.css index b3318fe14b..3d79d93ae6 100644 --- a/layouts/DocsPage/Navigation.module.css +++ b/layouts/DocsPage/Navigation.module.css @@ -107,12 +107,23 @@ display: block; } - & .link { - padding-left: var(--m-4); + & .link{ font-size: var(--fs-text-sm); line-height: var(--lh-md); } + & .link-1 { + padding-left: var(--m-4); + } + + & .link-2 { + padding-left: var(--m-5); + } + + & .link-3 { + padding-left: var(--m-6); + } + .link.active + & { display: block; } diff --git a/layouts/DocsPage/Navigation.stories.tsx b/layouts/DocsPage/Navigation.stories.tsx new file mode 100644 index 0000000000..cd3e40fc91 --- /dev/null +++ b/layouts/DocsPage/Navigation.stories.tsx @@ -0,0 +1,49 @@ +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 { NavigationCategory } from "./types"; + +export const NavigationFourLevels = () => { + const data = [ + { + icon: "cloud", + 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 ( + } + section={true} + currentVersion="16.x" + currentPathGetter={() => { + return "/enroll-resources/machine-id/deployment/aws/"; + }} + > + ); +}; + +const meta: Meta = { + title: "layouts/DocNavigation", + component: NavigationFourLevels, +}; +export default meta; diff --git a/layouts/DocsPage/Navigation.tsx b/layouts/DocsPage/Navigation.tsx index 07ad63b558..4de84d9678 100644 --- a/layouts/DocsPage/Navigation.tsx +++ b/layouts/DocsPage/Navigation.tsx @@ -24,14 +24,22 @@ const SCOPE_DICTIONARY: Record = { interface DocsNavigationItemsProps { entries: NavigationItem[]; onClick: () => void; + currentPath: string; + level?: number; } const DocsNavigationItems = ({ entries, onClick, + currentPath, + level, }: DocsNavigationItemsProps) => { - const docPath = useCurrentHref().split(SCOPELESS_HREF_REGEX)[0]; + const docPath = currentPath.split(SCOPELESS_HREF_REGEX)[0]; const { getVersionAgnosticRoute } = useVersionAgnosticPages(); + const maxLevel = 3; + if (!level) { + level = 1; + } return ( <> @@ -39,13 +47,15 @@ const DocsNavigationItems = ({ entries.map((entry) => { const selected = entry.slug === docPath; const active = - selected || entry.entries?.some((entry) => entry.slug === docPath); + selected || + entry.entries?.some((entry) => docPath.startsWith(entry.slug)); return (
  • )} - {!!entry.entries?.length && ( + {!!entry.entries?.length && level <= maxLevel && (
    )} @@ -77,6 +89,7 @@ interface DocNavigationCategoryProps extends NavigationCategory { opened: boolean; onToggleOpened: (value: number) => void; onClick: () => void; + currentPath: string; } const DocNavigationCategory = ({ @@ -87,6 +100,7 @@ const DocNavigationCategory = ({ icon, title, entries, + currentPath, }: DocNavigationCategoryProps) => { const toggleOpened = useCallback( () => onToggleOpened(opened ? null : id), @@ -105,7 +119,11 @@ const DocNavigationCategory = ({ {opened && (
      - +
    )} @@ -134,14 +152,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 +194,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/server/remark-toc.ts b/server/remark-toc.ts index 15bd2c88bd..d66e13936d 100644 --- a/server/remark-toc.ts +++ b/server/remark-toc.ts @@ -25,7 +25,7 @@ const relativePathToFile = (root: string, filepath: string) => { // properties: // - result: a string containing the resulting list of links. // - error: an error message encountered during processing -export const getTOC = (filePath: string, fs = nodeFS) => { +export const getTOC = (filePath: string, fs: any = nodeFS) => { const dirPath = path.dirname(filePath); if (!fs.existsSync(dirPath)) { return { 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();