Skip to content

Commit

Permalink
feat: tree-aware version switcher (#1593)
Browse files Browse the repository at this point in the history
  • Loading branch information
abvthecity authored Oct 5, 2024
1 parent 80af5e5 commit 6801d8a
Show file tree
Hide file tree
Showing 10 changed files with 234 additions and 22 deletions.
2 changes: 1 addition & 1 deletion packages/commons/fdr-utils/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export interface ColorsConfig {
dark: DocsV1Read.ThemeConfig | undefined;
}

export interface SidebarVersionInfo {
export interface VersionSwitcherInfo {
id: FernNavigation.VersionId;
title: string;
slug: FernNavigation.Slug;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Slug } from "../..";
import { toUnversionedSlug } from "../toUnversionedSlug";

describe("toUnversionedSlug", () => {
it("should trim version slug from the beginning of the slug", () => {
expect(toUnversionedSlug(Slug("docs/v1.0.0/foo/bar"), Slug("docs/v1.0.0"))).toBe("foo/bar");
});

it("should not trim version slug if it does not match", () => {
expect(toUnversionedSlug(Slug("docs/v1.0.0/foo/bar"), Slug("docs/v2.0.0"))).toBe("docs/v1.0.0/foo/bar");
});

it("should not trim version slug if it does not match exactly", () => {
expect(toUnversionedSlug(Slug("docs/v1.0.0/foo/bar"), Slug("docs/v1.0"))).toBe("docs/v1.0.0/foo/bar");
});

it("should return empty string if the slug is the same as the version slug", () => {
expect(toUnversionedSlug(Slug("docs/v1.0.0"), Slug("docs/v1.0.0"))).toBe("");
});
});
1 change: 1 addition & 0 deletions packages/fdr-sdk/src/navigation/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from "./findNode";
export * from "./getApiReferenceId";
export * from "./getNoIndexFromFrontmatter";
export * from "./toRootNode";
export * from "./toUnversionedSlug";
10 changes: 10 additions & 0 deletions packages/fdr-sdk/src/navigation/utils/toUnversionedSlug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Slug } from "..";

/**
* This is the part of the slug after the version (or basepath) prefix.
*
* For example, if the original slug is "docs/v1.0.0/foo/bar", the unversionedSlug is "foo/bar".
*/
export function toUnversionedSlug(slug: Slug, versionSlug: Slug): Slug {
return Slug(slug.replace(new RegExp(`^${versionSlug}(/|$)`), ""));
}
7 changes: 6 additions & 1 deletion packages/fdr-sdk/src/navigation/versions/latest/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
export * from "../../../client/generated/api/resources/commons";
export * from "../../../client/generated/api/resources/navigation/resources/latest/types";
export * from "./getPageId";
export * from "./NavigationNode";
export * from "./NavigationNodeApiLeaf";
export * from "./NavigationNodeLeaf";
Expand All @@ -12,6 +11,12 @@ export * from "./NavigationNodeSection";
export * from "./NavigationNodeSectionOverview";
export * from "./NavigationNodeWithMetadata";
export * from "./NavigationNodeWithRedirect";
export * from "./getPageId";
export * from "./isApiReferenceNode";
export * from "./isSidebarRootNode";
export * from "./isTabbedNode";
export * from "./isUnversionedNode";
export * from "./isVersionNode";
export * from "./slugjoin";
export * from "./toDefaultSlug";
export * from "./traverseNavigation";
4 changes: 2 additions & 2 deletions packages/ui/app/src/atoms/navigation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { DocsV1Read } from "@fern-api/fdr-sdk/client/types";
import * as FernNavigation from "@fern-api/fdr-sdk/navigation";
import { SidebarTab, SidebarVersionInfo } from "@fern-ui/fdr-utils";
import { SidebarTab, VersionSwitcherInfo } from "@fern-ui/fdr-utils";
import { atom, useAtomValue } from "jotai";
import { selectAtom } from "jotai/utils";
import { isEqual } from "lodash-es";
Expand All @@ -19,7 +19,7 @@ TABS_ATOM.debugLabel = "TABS_ATOM";

export const VERSIONS_ATOM = selectAtom(
DOCS_ATOM,
(docs): ReadonlyArray<SidebarVersionInfo> => docs.navigation.versions,
(docs): ReadonlyArray<VersionSwitcherInfo> => docs.navigation.versions,
isEqual,
);
VERSIONS_ATOM.debugLabel = "VERSIONS_ATOM";
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/app/src/atoms/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { DocsV1Read, DocsV2Read, FdrAPI } from "@fern-api/fdr-sdk/client/types";
import type * as FernDocs from "@fern-api/fdr-sdk/docs";
import type * as FernNavigation from "@fern-api/fdr-sdk/navigation";
import { ColorsConfig, SidebarTab, SidebarVersionInfo } from "@fern-ui/fdr-utils";
import { ColorsConfig, SidebarTab, VersionSwitcherInfo } from "@fern-ui/fdr-utils";
import { NextSeoProps } from "@fern-ui/next-seo";
import { CustomerAnalytics } from "../analytics/types";
import { FernUser } from "../auth";
Expand Down Expand Up @@ -45,7 +45,7 @@ export interface NavigationProps {
currentTabIndex: number | undefined;
tabs: SidebarTab[];
currentVersionId: FernNavigation.VersionId | undefined;
versions: SidebarVersionInfo[];
versions: VersionSwitcherInfo[];
sidebar: FernNavigation.SidebarRootNode | undefined;
trailingSlash: boolean;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { FernNavigation } from "@fern-api/fdr-sdk";
import { getNodesUnderCurrentVersionAscending } from "../withVersionSwitcherInfo";

describe("getNodesUnderCurrentVersionAscending", () => {
it("should return nodes under the current version in ascending order", () => {
const { version, nodes } = getNodesUnderCurrentVersionAscending<
{ type: FernNavigation.NavigationNode["type"]; id: string },
{ type: FernNavigation.VersionNode["type"]; id: string }
>({ type: "page", id: "a" }, [
{ type: "version", id: "d" },
{ type: "tab", id: "c" },
{ type: "section", id: "b" },
]);

expect(version).toEqual({ type: "version", id: "d" });
expect(nodes.map((node) => node.id)).toEqual(["a", "b", "c"]);
});

it("should return nothing if a version node is not found", () => {
const { version, nodes } = getNodesUnderCurrentVersionAscending<
{ type: FernNavigation.NavigationNode["type"]; id: string },
{ type: FernNavigation.VersionNode["type"]; id: string }
>({ type: "page", id: "a" }, [
{ type: "tab", id: "c" },
{ type: "section", id: "b" },
]);

expect(version).toBeUndefined();
expect(nodes.map((node) => node.id)).toEqual([]);
});
});
23 changes: 7 additions & 16 deletions packages/ui/docs-bundle/src/server/withInitialProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { getCustomerAnalytics } from "./getCustomerAnalytics";
import { handleLoadDocsError } from "./handleLoadDocsError";
import type { LoadWithUrlResponse } from "./loadWithUrl";
import { isTrailingSlashEnabled } from "./trailingSlash";
import { withVersionSwitcherInfo } from "./withVersionSwitcherInfo";

interface WithInitialProps {
docs: LoadWithUrlResponse;
Expand Down Expand Up @@ -135,22 +136,12 @@ export async function withInitialProps({
: undefined,
};

const versions = node.versions
.filter((version) => !version.hidden)
.map((version, index) => {
// if the same page exists in multiple versions, return the full slug of that page, otherwise default to version's landing page (pointsTo)
const expectedSlug = FernNavigation.slugjoin(version.slug, node.unversionedSlug);
const pointsTo = node.collector.slugMap.has(expectedSlug) ? expectedSlug : version.pointsTo;

return {
title: version.title,
id: version.versionId,
slug: version.slug,
pointsTo,
index,
availability: version.availability,
};
});
const versions = withVersionSwitcherInfo({
node: node.node,
parents: node.parents,
versions: node.versions,
slugMap: node.collector.slugMap,
});

const logoHref =
docs.definition.config.logoHref ??
Expand Down
154 changes: 154 additions & 0 deletions packages/ui/docs-bundle/src/server/withVersionSwitcherInfo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import * as FernNavigation from "@fern-api/fdr-sdk/navigation";
import { isNonNullish } from "@fern-ui/core-utils";
import { VersionSwitcherInfo } from "@fern-ui/fdr-utils";

interface WithVersionSwitcherInfoArgs {
/**
* The current node to mutate the version switcher info for.
*/
node: FernNavigation.NavigationNodeWithMetadata;

/**
* The parents of the current node, in descending order.
*/
parents: readonly FernNavigation.NavigationNode[];

/**
* All available versions to be rendered in the version switcher.
*/
versions: readonly FernNavigation.VersionNode[];

/**
* A map of slugs to nodes for ALL nodes in the tree.
* This is used to check if a node exists in a different version.s
*/
slugMap: Map<string, FernNavigation.NavigationNodeWithMetadata>;
}

/**
* In order to preserve the "context" of the current node when switching between versions, we need to check if
* the current node exists in the other versions. If it doesn't, we can traverse up the tree to find the closest
* page that exists on that version.
*
* For example, if the current node is `/docs/v1/foo/bar`, and the user switches to `/docs/v2`, we need to find
* if `/docs/v2/foo/bar` exists. If it doesn't, we can try `/docs/v2/foo` and so on.
*
* ASSUMPTIONS:
* This function relies on the slug and parent slugs to match between the versions.
* If the slug is overridden in frontmatter, there is not currently a way to determine the correct slug to switch to.
*/
export function withVersionSwitcherInfo({
node,
parents,
versions,
slugMap,
}: WithVersionSwitcherInfoArgs): VersionSwitcherInfo[] {
const { version: currentVersion, nodes } = getNodesUnderCurrentVersionAscending(node, parents);

const unversionedSlugs =
currentVersion == null
? []
: nodes
.filter(FernNavigation.hasMetadata)
.map((node) => node.slug)
.map((slug) => FernNavigation.utils.toUnversionedSlug(slug, currentVersion.slug));

return versions
.filter((version) => !version.hidden)
.map((version, index) => {
if (version.id === currentVersion?.id) {
return {
title: version.title,
id: version.versionId,
slug: version.slug,

// the current version should always point to the current node
pointsTo: node.slug,
index,
availability: version.availability,
};
}

const expectedSlugs = unversionedSlugs.map((slug) => FernNavigation.slugjoin(version.slug, slug));

const expectedSlug = expectedSlugs
.map((slug) => {
const node = slugMap.get(slug);

// if the node doesn't exist in this version, return undefined
if (node == null) {
return undefined;
}

// if the node is a visitable page, return the slug
else if (FernNavigation.isPage(node)) {
return node.slug;
}

// if the node is a redirect, return the slug it points to (which can be undefined)
else if (FernNavigation.hasRedirect(node)) {
return node.pointsTo;
}

return undefined;
})
// select the first non-nullish slug
.filter(isNonNullish)[0];

// if the same page exists in this version, return the full slug of that page, otherwise default to version's landing page (pointsTo)
const pointsTo = expectedSlug ?? version.pointsTo;

return {
title: version.title,
id: version.versionId,
slug: version.slug,
pointsTo,
index,
availability: version.availability,
};
});
}

/**
* This function returns the current node + all parents under the current version, in ascending order
*
* ascending order = from the current node to its ancestors
*
* @internal visibleForTesting
*/
export function getNodesUnderCurrentVersionAscending<
NODE extends { type: FernNavigation.NavigationNode["type"] } = FernNavigation.NavigationNode,
VERSION_NODE extends { type: FernNavigation.VersionNode["type"] } = FernNavigation.VersionNode,
>(
node: NODE,

/**
* The parents of the current node should be in descending order.
*/
parents: readonly NODE[],
): {
version: VERSION_NODE | undefined;
nodes: NODE[];
} {
const currentVersionIndex = parents.findIndex((node) => node.type === "version");

// if the current node is not under a version, return an empty array
if (currentVersionIndex < 0) {
return { version: undefined, nodes: [] };
}

const version = parents[currentVersionIndex];
if (version == null || version.type !== "version") {
return { version: undefined, nodes: [] };
}

parents = parents.slice(currentVersionIndex + 1);

return {
// this is safe because of the checks above
version: version as unknown as VERSION_NODE,

// reverse the array to get the nodes in ascending order
nodes: [...parents, node].reverse(),
};
}

0 comments on commit 6801d8a

Please sign in to comment.