From cb3634a1aa8ac0f401795edf934b244a9d67f47c Mon Sep 17 00:00:00 2001 From: Paul Gottschling Date: Fri, 7 Mar 2025 13:25:55 -0500 Subject: [PATCH 1/2] Add links to top-level sidebar categories Contributes to #87 Top-level sidebar categories do not have links, meaning that when a user clicks a sidebar category, it expands, but does not take the user to a category index page as they might expect. Category index pages are listed as regular pages among other regular pages (another change will handle the redundant menu page sidebar listings). This change edits the code that converts the `config.json` configuration file into a Docusaurus navigation config. It adds a Docusaurus `link` to each top-level navigation category based on the root path segment shared by all slugs within the category. --- server/config-docs.test.ts | 161 +++++++++++++++++++++++++++++++++++++ server/config-docs.ts | 119 +++++++++++++++++++++------ 2 files changed, 254 insertions(+), 26 deletions(-) create mode 100644 server/config-docs.test.ts diff --git a/server/config-docs.test.ts b/server/config-docs.test.ts new file mode 100644 index 0000000..e406f30 --- /dev/null +++ b/server/config-docs.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from "@jest/globals"; +import { + makeDocusaurusNavigationCategory, + getIndexPageID, + NavigationCategory, + DocusaurusCategory, +} from "./config-docs"; + +describe("getIndexPageID", () => { + interface testCase { + description: string; + category: NavigationCategory; + expected: string; + } + + const testCases: Array = [ + { + description: "non-generated", + category: { + icon: "connect", + title: "User Guides", + entries: [ + { + title: "Introduction", + slug: "/ver/18.x/connect-your-client/introduction/", + }, + { + title: "Using tsh", + slug: "/ver/18.x/connect-your-client/tsh/", + }, + ], + }, + expected: "connect-your-client/connect-your-client", + }, + { + description: "includes root index page with versioned path", + category: { + icon: "home", + title: "Get Started", + entries: [ + { + title: "Docs Home", + slug: "/ver/18.x/", + }, + { + title: "Installation", + slug: "/ver/18.x/installation/", + }, + ], + }, + expected: "index", + }, + { + description: "includes root index page with root path", + category: { + icon: "home", + title: "Get Started", + entries: [ + { + title: "Docs Home", + slug: "/", + }, + { + title: "Installation", + slug: "/installation/", + }, + ], + }, + expected: "index", + }, + { + description: "generated", + category: { + icon: "wrench", + title: "Admin Guides", + entries: [], + generateFrom: "admin-guides", + }, + expected: "admin-guides/admin-guides", + }, + ]; + + test.each(testCases)("$description", (c) => { + expect(getIndexPageID(c.category)).toEqual(c.expected); + }); +}); + +describe("makeDocusaurusNavigationCategory", () => { + interface testCase { + description: string; + category: NavigationCategory; + expected: DocusaurusCategory; + } + + const testCases: Array = [ + { + description: "non-generated", + category: { + icon: "connect", + title: "User Guides", + entries: [ + { + title: "Introduction", + slug: "/ver/18.x/connect-your-client/introduction/", + }, + { + title: "Using tsh", + slug: "/ver/18.x/connect-your-client/tsh/", + }, + ], + }, + expected: { + collapsible: true, + link: { type: "doc", id: "connect-your-client/connect-your-client" }, + items: [ + { + id: "connect-your-client/introduction", + label: "Introduction", + type: "doc", + }, + { + id: "connect-your-client/tsh", + label: "Using tsh", + type: "doc", + }, + ], + label: "User Guides", + type: "category", + }, + }, + { + description: "generated", + category: { + icon: "wrench", + title: "Admin Guides", + entries: [], + generateFrom: "admin-guides", + }, + expected: { + items: [ + { + dirName: "admin-guides", + type: "autogenerated", + }, + ], + label: "Admin Guides", + link: { + id: "admin-guides/admin-guides", + type: "doc", + }, + type: "category", + }, + }, + ]; + + test.each(testCases)("$description", (c) => { + expect(makeDocusaurusNavigationCategory(c.category, "18.x")).toEqual( + c.expected + ); + }); +}); diff --git a/server/config-docs.ts b/server/config-docs.ts index 9cd53ec..88eef08 100644 --- a/server/config-docs.ts +++ b/server/config-docs.ts @@ -6,7 +6,7 @@ import Ajv from "ajv"; import { validateConfig } from "./config-common"; -import { resolve } from "path"; +import { resolve, sep } from "path"; import { existsSync, readFileSync } from "fs"; import { isExternalLink, isHash, splitPath } from "../src/utils/url"; import { getLatestVersion } from "./config-site"; @@ -51,14 +51,14 @@ interface BaseNavigationItem { title: string; slug: string; entries?: NavigationItem[]; - generateFrom: string; + generateFrom?: string; } export interface RawNavigationItem extends BaseNavigationItem { forScopes?: ScopeType[]; } export interface NavigationItem extends BaseNavigationItem { - forScopes: ScopesInMeta; + forScopes?: ScopesInMeta; } export interface NavigationCategory { @@ -350,33 +350,100 @@ const makeDocusaurusCategoryFromEntry = ( return category; }; +// Docusaurus doesn't export the types it uses internally for sidebar +// categories, and these are a little involved, so we'll accept any object. +// See: +// https://github.com/facebook/docusaurus/blob/main/packages/docusaurus-plugin-content-docs/src/sidebars/types.ts +export interface DocusaurusCategory { + [propName: string]: unknown; +} + +// categoryDirPattern matches a slug that has been normalized to include a +// version, or the default version. Examples: +// - /ver/10.x/installation/ +// - /database-access/introduction/ +const categoryDirPattern = `(/ver/[0-9]+\.x)?/([^/]*)`; + +// getIndexPageID infers the Docusaurus page ID of a category index page based +// on the slugs of pages within the category. The legacy config.json format does +// not specify the slugs of category index pages within each top-level category +// so we need to generate this to add links to these pages in the Docusaurus +// sidebar. +export const getIndexPageID = (category: NavigationCategory): string => { + if (category.entries.length == 0 && !category.generateFrom) { + throw new Error( + `a navigation category with no generateFrom property must have entries` + ); + } + + // Base the ID on the directory we generated the sidebar from in the legacy + // docs site. + if (!!category.generateFrom) { + return category.generateFrom + "/" + category.generateFrom; + } + + // Check if the entries contain the root index page. If they do, use that ID + if (category.entries.some((e) => e.slug.match(categoryDirPattern)[2] == "")) { + return "index"; + } + + // The sidebar is manually defined, so base the category index page ID on + // the first-level directory that contains all entries in the category. + let categoryIndexDir: string; + for (let i = 0; i < category.entries.length; i++) { + const rootDirName = category.entries[i].slug.match(categoryDirPattern)[2]; + if (!categoryIndexDir) { + categoryIndexDir = rootDirName; + continue; + } + if (rootDirName != categoryIndexDir) { + throw new Error( + `cannot determine a category index page ID for top-level category ${category.title} because not all of its entries are in the same first-level directory` + ); + } + } + return categoryIndexDir + "/" + categoryIndexDir; +}; + +// makeDocusaurusNavigationCategory converts one top-level navigation category +// as specified in the legacy configuration format (config.json) to the +// Docusaurus configuration format. +export const makeDocusaurusNavigationCategory = ( + category: NavigationCategory, + version: string +) => { + if (category.generateFrom) { + return { + type: "category", + label: category.title, + link: { type: "doc", id: getIndexPageID(category) }, + items: [ + { + type: "autogenerated", + dirName: category.generateFrom, + }, + ], + }; + } + return { + type: "category", + label: category.title, + collapsible: true, + link: { type: "doc", id: getIndexPageID(category) }, + items: category.entries.map((entry) => + entry.entries + ? makeDocusaurusCategoryFromEntry(entry, version) + : makeDocusaurusDocFromEntry(entry, version) + ), + }; +}; + export const docusaurusifyNavigation = (version: string) => { - const config = loadConfig(version); + const config: Config = loadConfig(version); return { docs: config.navigation.map((category) => { - if (category.generateFrom) { - return { - type: "category", - label: category.title, - items: [ - { - type: "autogenerated", - dirName: category.generateFrom, - }, - ], - }; - } - return { - type: "category", - label: category.title, - collapsible: true, - items: category.entries.map((entry) => - entry.entries - ? makeDocusaurusCategoryFromEntry(entry, version) - : makeDocusaurusDocFromEntry(entry, version) - ), - }; + return makeDocusaurusNavigationCategory(category, version); }), }; }; From 2ad40d73785bda97bccda8a4f93ebb347cace81d Mon Sep 17 00:00:00 2001 From: Paul Gottschling Date: Tue, 18 Mar 2025 11:44:57 -0400 Subject: [PATCH 2/2] Respond to feedback - Remove unnecessary double negation. - Add an escape character to `categoryDirPattern` - Handle invalid entry slugs and add tests for invalid `getIndexPageID` cases --- server/config-docs.test.ts | 55 +++++++++++++++++++++++++++++++++++++- server/config-docs.ts | 23 +++++++++++----- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/server/config-docs.test.ts b/server/config-docs.test.ts index e406f30..a3033df 100644 --- a/server/config-docs.test.ts +++ b/server/config-docs.test.ts @@ -6,7 +6,7 @@ import { DocusaurusCategory, } from "./config-docs"; -describe("getIndexPageID", () => { +describe("getIndexPageID with valid entries", () => { interface testCase { description: string; category: NavigationCategory; @@ -85,6 +85,59 @@ describe("getIndexPageID", () => { }); }); +describe("getIndexPageID with invalid entries", () => { + interface testCase { + description: string; + category: NavigationCategory; + errorSubstring: string; + } + + const testCases: Array = [ + { + description: "non-generated with malformed slugs", + category: { + icon: "connect", + title: "User Guides", + entries: [ + { + title: "Introduction", + slug: "", + }, + { + title: "Using tsh", + slug: "/docs/connect-your-client/tsh/", + }, + ], + }, + errorSubstring: `malformed slug in docs sidebar configuration: ""`, + }, + { + description: "slugs with different top-level segments", + category: { + icon: "connect", + title: "User Guides", + entries: [ + { + title: "Introduction", + slug: "/ver/12.x/enroll-resources/introduction/", + }, + { + title: "Using tsh", + slug: "/ver/12.x/connect-your-client/tsh/", + }, + ], + }, + errorSubstring: `cannot determine a category index page ID for top-level category User Guides because not all of its entries are in the same first-level directory`, + }, + ]; + + test.each(testCases)("$description", (c) => { + expect(() => { + getIndexPageID(c.category); + }).toThrow(c.errorSubstring); + }); +}); + describe("makeDocusaurusNavigationCategory", () => { interface testCase { description: string; diff --git a/server/config-docs.ts b/server/config-docs.ts index 88eef08..733a640 100644 --- a/server/config-docs.ts +++ b/server/config-docs.ts @@ -362,7 +362,7 @@ export interface DocusaurusCategory { // version, or the default version. Examples: // - /ver/10.x/installation/ // - /database-access/introduction/ -const categoryDirPattern = `(/ver/[0-9]+\.x)?/([^/]*)`; +const categoryDirPattern = `(/ver/[0-9]+\\.x)?/([^/]*)`; // getIndexPageID infers the Docusaurus page ID of a category index page based // on the slugs of pages within the category. The legacy config.json format does @@ -378,25 +378,34 @@ export const getIndexPageID = (category: NavigationCategory): string => { // Base the ID on the directory we generated the sidebar from in the legacy // docs site. - if (!!category.generateFrom) { + if (category.generateFrom) { return category.generateFrom + "/" + category.generateFrom; } + const slugSegments = category.entries.map((e) => { + const parts = e.slug.match(categoryDirPattern); + if (!parts) { + throw new Error( + `malformed slug in docs sidebar configuration: "${e.slug}"` + ); + } + return parts[2]; + }); + // Check if the entries contain the root index page. If they do, use that ID - if (category.entries.some((e) => e.slug.match(categoryDirPattern)[2] == "")) { + if (slugSegments.some((e) => e == "")) { return "index"; } // The sidebar is manually defined, so base the category index page ID on // the first-level directory that contains all entries in the category. let categoryIndexDir: string; - for (let i = 0; i < category.entries.length; i++) { - const rootDirName = category.entries[i].slug.match(categoryDirPattern)[2]; + for (let i = 0; i < slugSegments.length; i++) { if (!categoryIndexDir) { - categoryIndexDir = rootDirName; + categoryIndexDir = slugSegments[i]; continue; } - if (rootDirName != categoryIndexDir) { + if (slugSegments[i] != categoryIndexDir) { throw new Error( `cannot determine a category index page ID for top-level category ${category.title} because not all of its entries are in the same first-level directory` );