Skip to content
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

feat: tree-aware version switcher #1593

Merged
merged 3 commits into from
Oct 5, 2024
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
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"]);
});
dsinghvi marked this conversation as resolved.
Show resolved Hide resolved

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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how would we solve this edge case? (sounds like you would need to traverse nodes in the navigation?)

*/
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(),
};
}
Loading