Skip to content

Add links to top-level sidebar categories #143

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Mar 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions server/config-docs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { describe, expect, test } from "@jest/globals";
import {
makeDocusaurusNavigationCategory,
getIndexPageID,
NavigationCategory,
DocusaurusCategory,
} from "./config-docs";

describe("getIndexPageID with valid entries", () => {
interface testCase {
description: string;
category: NavigationCategory;
expected: string;
}

const testCases: Array<testCase> = [
{
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("getIndexPageID with invalid entries", () => {
interface testCase {
description: string;
category: NavigationCategory;
errorSubstring: string;
}

const testCases: Array<testCase> = [
{
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;
category: NavigationCategory;
expected: DocusaurusCategory;
}

const testCases: Array<testCase> = [
{
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
);
});
});
128 changes: 102 additions & 26 deletions server/config-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -350,33 +350,109 @@ 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;
}

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 (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 < slugSegments.length; i++) {
if (!categoryIndexDir) {
categoryIndexDir = slugSegments[i];
continue;
}
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`
);
}
}
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);
}),
};
};
Loading