diff --git a/server/pages-helpers.ts b/server/pages-helpers.ts index b755648f09..4dfcb43e20 100644 --- a/server/pages-helpers.ts +++ b/server/pages-helpers.ts @@ -72,6 +72,42 @@ const getEntryForPath = (fs, filePath) => { }; }; +// sortByTitle takes two navigation entries, a and b, and sorts them in +// alphabetically ascending order by their "title" field. If either title +// includes the substring "introduction", sortByTitle sorts that entry first. +const sortByTitle = (a, b) => { + switch (true) { + case a.title.toLowerCase().includes("introduction"): + return -1; + break; + case b.title.toLowerCase().includes("introduction"): + return 1; + break; + default: + return a.title < b.title ? -1 : 1; + } +}; + +// categoryPagePathForDir looks for a category page at the same directory level +// as its associated directory OR within the associated directory. Throws an +// error if there is no category page for the directory. +const categoryPagePathForDir = (fs, dirPath) => { + const { name } = parse(dirPath); + + const outerCategoryPage = join(dirname(dirPath), name + ".mdx"); + const innerCategoryPage = join(dirPath, name + ".mdx"); + + if (fs.existsSync(outerCategoryPage)) { + return outerCategoryPage; + } + if (fs.existsSync(innerCategoryPage)) { + return innerCategoryPage; + } + throw new Error( + `subdirectory in generated sidebar section ${dirPath} has no category page ${innerCategoryPage} or ${outerCategoryPage}` + ); +}; + export const generateNavPaths = (fs, dirPath) => { const firstLvl = fs.readdirSync(dirPath, "utf8"); let result = []; @@ -86,53 +122,79 @@ export const generateNavPaths = (fs, dirPath) => { } firstLvlFiles.add(fullPath); }); - let sectionIntros = new Set(); - firstLvlDirs.forEach((d: string) => { - const { name } = parse(d); - const asFile = join(d, name + ".mdx"); - if (!fs.existsSync(asFile)) { - throw `subdirectory in generated sidebar section ${d} has no category page ${asFile}`; - } - sectionIntros.add(asFile); - return; + // Map category pages to the directories they introduce so we can can add a + // sidebar entry for the category page, then traverse the directory. + let sectionIntros = new Map(); + firstLvlDirs.forEach((d: string) => { + sectionIntros.set(categoryPagePathForDir(fs, d), d); }); // Add files with no corresponding directory to the navigation first. Section // introductions, by convention, have a filename that corresponds to the // subdirectory containing pages in the section, or have the name // "introduction.mdx". - firstLvlFiles.forEach((f) => { + firstLvlFiles.forEach((f: string) => { + // Handle section intros separately + if (sectionIntros.has(f)) { + return; + } + if (!f.endsWith(".mdx")) { + return; + } result.push(getEntryForPath(fs, f)); }); - sectionIntros.forEach((si: string) => { - const { slug, title } = getEntryForPath(fs, si); + sectionIntros.forEach((dirPath, categoryPagePath) => { + const { slug, title } = getEntryForPath(fs, categoryPagePath); const section = { title: title, slug: slug, entries: [], }; - const sectionDir = dirname(si); - const secondLvl = fs.readdirSync(sectionDir, "utf8"); - secondLvl.forEach((f2) => { - const { name } = parse(f2); - - // The directory name is the same as the filename, meaning that we have - // already used this as a category page. - if (sectionDir.endsWith(name)) { + 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); - const fullPath2 = join(sectionDir, f2); - const stat = fs.statSync(fullPath2); - if (stat.isDirectory()) { + // 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); result.push(section); }); + result.sort(sortByTitle); return result; }; diff --git a/uvu-tests/config-docs.test.ts b/uvu-tests/config-docs.test.ts index 7db74aa009..bdef7dc9a4 100644 --- a/uvu-tests/config-docs.test.ts +++ b/uvu-tests/config-docs.test.ts @@ -139,10 +139,6 @@ title: Database RBAC Reference }; const expected = [ - { - title: "Protect Databases with Teleport", - slug: "/database-access/introduction/", - }, { title: "Database Access Guides", slug: "/database-access/guides/guides/", @@ -161,16 +157,20 @@ title: Database RBAC Reference title: "Database Access RBAC", slug: "/database-access/rbac/rbac/", entries: [ - { - title: "Get Started with DB RBAC", - slug: "/database-access/rbac/get-started/", - }, { title: "Database RBAC Reference", slug: "/database-access/rbac/reference/", }, + { + title: "Get Started with DB RBAC", + slug: "/database-access/rbac/get-started/", + }, ], }, + { + title: "Protect Databases with Teleport", + slug: "/database-access/introduction/", + }, ]; const vol = Volume.fromJSON(files); @@ -179,6 +179,96 @@ title: Database RBAC Reference assert.equal(actual, expected); }); +Suite( + "generateNavPaths alphabetizes second-level links except 'Introduction'", + () => { + const files = { + "/docs/pages/database-access/mongodb.mdx": `--- +title: MongoDB +---`, + "/docs/pages/database-access/azure-dbs.mdx": `--- +title: Azure +---`, + "/docs/pages/database-access/introduction.mdx": `--- +title: Introduction to Database Access +---`, + }; + + const expected = [ + { + title: "Introduction to Database Access", + slug: "/database-access/introduction/", + }, + { + title: "Azure", + slug: "/database-access/azure-dbs/", + }, + { + title: "MongoDB", + slug: "/database-access/mongodb/", + }, + ]; + + const vol = Volume.fromJSON(files); + const fs = createFsFromVolume(vol); + const actual = generateNavPaths(fs, "/docs/pages/database-access"); + assert.equal(actual, expected); + } +); + +Suite( + "generateNavPaths alphabetizes third-level links except 'Introduction'", + () => { + const files = { + "/docs/pages/database-access/guides/guides.mdx": `--- +title: Database Access Guides +---`, + "/docs/pages/database-access/guides/postgres.mdx": `--- +title: Postgres Guide +---`, + "/docs/pages/database-access/guides/mysql.mdx": `--- +title: MySQL Guide +---`, + "/docs/pages/database-access/guides/get-started.mdx": `--- +title: Introduction to Database RBAC +---`, + "/docs/pages/database-access/guides/reference.mdx": `--- +title: Database RBAC Reference +---`, + }; + + const expected = [ + { + title: "Database Access Guides", + slug: "/database-access/guides/guides/", + entries: [ + { + title: "Introduction to Database RBAC", + slug: "/database-access/guides/get-started/", + }, + { + title: "Database RBAC Reference", + slug: "/database-access/guides/reference/", + }, + { + 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); + } +); + Suite( "generateNavPaths throws if there is no category page in a subdirectory", () => { @@ -199,4 +289,102 @@ title: MySQL Guide } ); +Suite( + "generateNavPaths shows third-level category pages on the sidebar", + () => { + const files = { + "/docs/pages/database-access/guides/guides.mdx": `--- +title: Database Access Guides +---`, + "/docs/pages/database-access/guides/postgres.mdx": `--- +title: Postgres Guide +---`, + "/docs/pages/database-access/guides/mysql.mdx": `--- +title: MySQL Guide +---`, + "/docs/pages/database-access/guides/rbac/rbac.mdx": `--- +title: Database Access RBAC +---`, + "/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 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", + () => { + const files = { + "/docs/pages/database-access/guides.mdx": `--- +title: Database Access Guides +---`, + "/docs/pages/database-access/guides/postgres.mdx": `--- +title: Postgres Guide +---`, + "/docs/pages/database-access/guides/mysql.mdx": `--- +title: MySQL Guide +---`, + "/docs/pages/database-access/guides/rbac.mdx": `--- +title: Database Access RBAC +---`, + "/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/", + entries: [ + { + title: "Database Access RBAC", + slug: "/database-access/guides/rbac/", + }, + { + 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); + let actual = generateNavPaths(fs, "/docs/pages/database-access"); + assert.equal(actual, expected); + } +); + Suite.run();