From 520edee894bf8eaba5ffceb3856ce84664cbe27d Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Fri, 4 Oct 2024 12:57:48 -0400 Subject: [PATCH] make functions abstract, polyfill structuredClone, etc --- .../src/getSlugForSearchRecord.ts | 4 +- packages/fdr-sdk/package.json | 1 + packages/fdr-sdk/src/declarations.d.ts | 5 + .../fdr-sdk/src/navigation/NodeCollector.ts | 17 +- .../__test__/pruneNavigationTree.test.ts | 98 ++++++++ .../navigation/utils/collectApiReferences.ts | 2 +- .../src/navigation/utils/collectPageIds.ts | 2 +- .../src/navigation/utils/createBreadcrumbs.ts | 2 +- .../src/navigation/utils/deleteChild.ts | 72 ++++++ .../fdr-sdk/src/navigation/utils/findNode.ts | 8 +- .../src/navigation/utils/followRedirect.ts | 70 +++--- .../src/navigation/utils/hasChildren.ts | 4 +- .../navigation/utils/pruneNavigationTree.ts | 149 +++--------- .../src/navigation/utils/pruneVersionNode.ts | 2 +- .../src/navigation/utils/updatePointsTo.ts | 10 +- ...ithChildren.ts => NavigationNodeParent.ts} | 2 +- .../navigation/versions/latest/getChildren.ts | 37 +++ .../src/navigation/versions/latest/index.ts | 6 +- .../navigation/versions/latest/traverseBF.ts | 14 ++ .../navigation/versions/latest/traverseDF.ts | 12 + .../versions/latest/traverseNavigation.ts | 221 ------------------ .../src/utils/traversers/__test__/bfs.test.ts | 71 ++++++ .../src/utils/traversers/__test__/dfs.test.ts | 69 ++++++ .../src/utils/traversers/__test__/fixture.ts | 42 ++++ .../traversers/__test__/prunetree.test.ts | 95 ++++++++ packages/fdr-sdk/src/utils/traversers/bfs.ts | 27 +++ packages/fdr-sdk/src/utils/traversers/dfs.ts | 26 +++ .../fdr-sdk/src/utils/traversers/prunetree.ts | 121 ++++++++++ .../fdr-sdk/src/utils/traversers/types.ts | 8 + packages/ui/app/src/atoms/sidebar.ts | 4 +- .../ui/app/src/util/resolveDocsContent.ts | 2 +- pnpm-lock.yaml | 80 +++---- 32 files changed, 834 insertions(+), 449 deletions(-) create mode 100644 packages/fdr-sdk/src/declarations.d.ts create mode 100644 packages/fdr-sdk/src/navigation/utils/__test__/pruneNavigationTree.test.ts create mode 100644 packages/fdr-sdk/src/navigation/utils/deleteChild.ts rename packages/fdr-sdk/src/navigation/versions/latest/{NavigationNodeWithChildren.ts => NavigationNodeParent.ts} (56%) create mode 100644 packages/fdr-sdk/src/navigation/versions/latest/getChildren.ts create mode 100644 packages/fdr-sdk/src/navigation/versions/latest/traverseBF.ts create mode 100644 packages/fdr-sdk/src/navigation/versions/latest/traverseDF.ts delete mode 100644 packages/fdr-sdk/src/navigation/versions/latest/traverseNavigation.ts create mode 100644 packages/fdr-sdk/src/utils/traversers/__test__/bfs.test.ts create mode 100644 packages/fdr-sdk/src/utils/traversers/__test__/dfs.test.ts create mode 100644 packages/fdr-sdk/src/utils/traversers/__test__/fixture.ts create mode 100644 packages/fdr-sdk/src/utils/traversers/__test__/prunetree.test.ts create mode 100644 packages/fdr-sdk/src/utils/traversers/bfs.ts create mode 100644 packages/fdr-sdk/src/utils/traversers/dfs.ts create mode 100644 packages/fdr-sdk/src/utils/traversers/prunetree.ts create mode 100644 packages/fdr-sdk/src/utils/traversers/types.ts diff --git a/packages/commons/search-utils/src/getSlugForSearchRecord.ts b/packages/commons/search-utils/src/getSlugForSearchRecord.ts index 21f465157c..54099f28cf 100644 --- a/packages/commons/search-utils/src/getSlugForSearchRecord.ts +++ b/packages/commons/search-utils/src/getSlugForSearchRecord.ts @@ -98,7 +98,7 @@ function createSearchPlaceholder(sidebar: FernNavigation.SidebarRootNode | undef function checkHasGuides(sidebar: FernNavigation.SidebarRootNode): boolean { let hasGuides = false; - FernNavigation.traverseNavigation(sidebar, (node) => { + FernNavigation.traverseBF(sidebar, (node) => { if (node.type === "page") { hasGuides = true; return false; @@ -113,7 +113,7 @@ function checkHasGuides(sidebar: FernNavigation.SidebarRootNode): boolean { function checkHasEndpoints(sidebar: FernNavigation.SidebarRootNode): boolean { let hasEndpoints = false; - FernNavigation.traverseNavigation(sidebar, (node) => { + FernNavigation.traverseBF(sidebar, (node) => { if (node.type === "apiReference") { hasEndpoints = true; return false; diff --git a/packages/fdr-sdk/package.json b/packages/fdr-sdk/package.json index 4f7a9d1ff7..e1c7bae3a4 100644 --- a/packages/fdr-sdk/package.json +++ b/packages/fdr-sdk/package.json @@ -32,6 +32,7 @@ }, "dependencies": { "@fern-ui/core-utils": "workspace:*", + "core-js-pure": "^3.38.1", "dayjs": "^1.11.11", "fast-deep-equal": "^3.1.3", "form-data": "4.0.0", diff --git a/packages/fdr-sdk/src/declarations.d.ts b/packages/fdr-sdk/src/declarations.d.ts new file mode 100644 index 0000000000..6951696c9b --- /dev/null +++ b/packages/fdr-sdk/src/declarations.d.ts @@ -0,0 +1,5 @@ +declare module "core-js-pure/actual/structured-clone" { + const structuredClone: (value: T) => T; + + export default structuredClone; +} diff --git a/packages/fdr-sdk/src/navigation/NodeCollector.ts b/packages/fdr-sdk/src/navigation/NodeCollector.ts index 1c98153904..210dd25b9f 100644 --- a/packages/fdr-sdk/src/navigation/NodeCollector.ts +++ b/packages/fdr-sdk/src/navigation/NodeCollector.ts @@ -1,10 +1,11 @@ +import { EMPTY_ARRAY } from "@fern-ui/core-utils"; import { once } from "../utils"; import { FernNavigation } from "./.."; import { pruneVersionNode } from "./utils/pruneVersionNode"; interface NavigationNodeWithMetadataAndParents { node: FernNavigation.NavigationNodeWithMetadata; - parents: FernNavigation.NavigationNode[]; + parents: readonly FernNavigation.NavigationNodeParent[]; next: FernNavigation.NavigationNodeNeighbor | undefined; prev: FernNavigation.NavigationNodeNeighbor | undefined; } @@ -14,7 +15,7 @@ const NodeCollectorInstances = new WeakMap(); - private idToNodeParents = new Map(); + private idToNodeParents = new Map(); private slugToNode = new Map(); private orphanedNodes: FernNavigation.NavigationNodeWithMetadata[] = []; @@ -36,7 +37,7 @@ export class NodeCollector { #setNode( slug: FernNavigation.Slug, node: FernNavigation.NavigationNodeWithMetadata, - parents: FernNavigation.NavigationNode[], + parents: readonly FernNavigation.NavigationNodeParent[], ) { const toSet = { node, parents, prev: this.#lastNeighboringNode, next: undefined }; this.slugToNode.set(slug, toSet); @@ -56,7 +57,7 @@ export class NodeCollector { if (rootNode == null) { return; } - FernNavigation.traverseNavigation(rootNode, (node, _index, parents) => { + FernNavigation.traverseDF(rootNode, (node, parents) => { // if the node is the default version, make a copy of it and "prune" the version slug from all children nodes if (node.type === "version") { this.versionNodes.push(node); @@ -65,7 +66,7 @@ export class NodeCollector { if (node.type === "version" && node.default && rootNode.type === "root") { const copy = JSON.parse(JSON.stringify(node)) as FernNavigation.VersionNode; this.defaultVersion = pruneVersionNode(copy, rootNode.slug, node.slug); - FernNavigation.traverseNavigation(this.defaultVersion, (node, _index, innerParents) => { + FernNavigation.traverseDF(this.defaultVersion, (node, innerParents) => { this.visitNode(node, [...parents, ...innerParents], true); }); } @@ -76,7 +77,7 @@ export class NodeCollector { private visitNode( node: FernNavigation.NavigationNode, - parents: FernNavigation.NavigationNode[], + parents: readonly FernNavigation.NavigationNodeParent[], isDefaultVersion = false, ): void { if (!this.idToNode.has(node.id) || isDefaultVersion) { @@ -137,8 +138,8 @@ export class NodeCollector { return this.idToNode.get(id); } - public getParents(id: FernNavigation.NodeId): FernNavigation.NavigationNode[] { - return this.idToNodeParents.get(id) ?? []; + public getParents(id: FernNavigation.NodeId): readonly FernNavigation.NavigationNodeParent[] { + return this.idToNodeParents.get(id) ?? EMPTY_ARRAY; } public getSlugMapWithParents = (): ReadonlyMap => { diff --git a/packages/fdr-sdk/src/navigation/utils/__test__/pruneNavigationTree.test.ts b/packages/fdr-sdk/src/navigation/utils/__test__/pruneNavigationTree.test.ts new file mode 100644 index 0000000000..0dfd3494ac --- /dev/null +++ b/packages/fdr-sdk/src/navigation/utils/__test__/pruneNavigationTree.test.ts @@ -0,0 +1,98 @@ +import { FernNavigation } from "../../.."; +import { pruneNavigationTree } from "../pruneNavigationTree"; + +describe("pruneNavigationTree", () => { + it("should not prune the tree if keep returns true for all nodes", () => { + const root: FernNavigation.NavigationNode = { + type: "section", + id: FernNavigation.NodeId("root"), + slug: FernNavigation.Slug("root"), + title: "Root", + children: [ + { + type: "page", + id: FernNavigation.NodeId("page"), + slug: FernNavigation.Slug("root/page"), + title: "Page", + pageId: FernNavigation.PageId("page.mdx"), + canonicalSlug: undefined, + icon: undefined, + hidden: undefined, + noindex: undefined, + }, + ], + collapsed: undefined, + canonicalSlug: undefined, + icon: undefined, + hidden: undefined, + overviewPageId: undefined, + noindex: undefined, + pointsTo: undefined, + }; + + const result = pruneNavigationTree(root, () => true); + + // structuredClone should duplicate the object + expect(result === root).toBe(false); + + expect(result).toStrictEqual({ + type: "section", + id: FernNavigation.NodeId("root"), + slug: FernNavigation.Slug("root"), + title: "Root", + children: [ + { + type: "page", + id: FernNavigation.NodeId("page"), + slug: FernNavigation.Slug("root/page"), + title: "Page", + pageId: FernNavigation.PageId("page.mdx"), + canonicalSlug: undefined, + icon: undefined, + hidden: undefined, + noindex: undefined, + }, + ], + collapsed: undefined, + canonicalSlug: undefined, + icon: undefined, + hidden: undefined, + overviewPageId: undefined, + noindex: undefined, + pointsTo: undefined, + }); + }); + + it("should return undefined if no visitable pages are left", () => { + const root: FernNavigation.NavigationNode = { + type: "section", + id: FernNavigation.NodeId("root"), + slug: FernNavigation.Slug("root"), + title: "Root", + children: [ + { + type: "page", + id: FernNavigation.NodeId("page"), + slug: FernNavigation.Slug("root/page"), + title: "Page", + pageId: FernNavigation.PageId("page.mdx"), + canonicalSlug: undefined, + icon: undefined, + hidden: undefined, + noindex: undefined, + }, + ], + collapsed: undefined, + canonicalSlug: undefined, + icon: undefined, + hidden: undefined, + overviewPageId: undefined, + noindex: undefined, + pointsTo: undefined, + }; + + const result = pruneNavigationTree(root, (node) => node.id !== FernNavigation.NodeId("page")); + + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/fdr-sdk/src/navigation/utils/collectApiReferences.ts b/packages/fdr-sdk/src/navigation/utils/collectApiReferences.ts index 8d08baf9b6..bf9a9eeeca 100644 --- a/packages/fdr-sdk/src/navigation/utils/collectApiReferences.ts +++ b/packages/fdr-sdk/src/navigation/utils/collectApiReferences.ts @@ -2,7 +2,7 @@ import { FernNavigation } from "../.."; export function collectApiReferences(nav: FernNavigation.NavigationNode): FernNavigation.ApiReferenceNode[] { const apiReferences: FernNavigation.ApiReferenceNode[] = []; - FernNavigation.traverseNavigation(nav, (node) => { + FernNavigation.traverseDF(nav, (node) => { if (node.type === "apiReference") { apiReferences.push(node); return "skip"; diff --git a/packages/fdr-sdk/src/navigation/utils/collectPageIds.ts b/packages/fdr-sdk/src/navigation/utils/collectPageIds.ts index 0658664870..a42b4ef87e 100644 --- a/packages/fdr-sdk/src/navigation/utils/collectPageIds.ts +++ b/packages/fdr-sdk/src/navigation/utils/collectPageIds.ts @@ -3,7 +3,7 @@ import { getPageId } from "../versions/latest/getPageId"; export function collectPageIds(nav: FernNavigation.NavigationNode): Set { const pageIds = new Set(); - FernNavigation.traverseNavigation(nav, (node) => { + FernNavigation.traverseDF(nav, (node) => { if (FernNavigation.isPage(node)) { const pageId = getPageId(node); if (pageId != null) { diff --git a/packages/fdr-sdk/src/navigation/utils/createBreadcrumbs.ts b/packages/fdr-sdk/src/navigation/utils/createBreadcrumbs.ts index 1f2c9b6577..a318411e40 100644 --- a/packages/fdr-sdk/src/navigation/utils/createBreadcrumbs.ts +++ b/packages/fdr-sdk/src/navigation/utils/createBreadcrumbs.ts @@ -2,7 +2,7 @@ import visitDiscriminatedUnion from "@fern-ui/core-utils/visitDiscriminatedUnion import { noop } from "ts-essentials"; import { FernNavigation } from "../.."; -export function createBreadcrumbs(nodes: FernNavigation.NavigationNode[]): FernNavigation.BreadcrumbItem[] { +export function createBreadcrumbs(nodes: readonly FernNavigation.NavigationNode[]): FernNavigation.BreadcrumbItem[] { const breadcrumb: FernNavigation.BreadcrumbItem[] = []; nodes.forEach((node) => { if (!FernNavigation.hasMetadata(node) || FernNavigation.isLeaf(node)) { diff --git a/packages/fdr-sdk/src/navigation/utils/deleteChild.ts b/packages/fdr-sdk/src/navigation/utils/deleteChild.ts new file mode 100644 index 0000000000..bd04391243 --- /dev/null +++ b/packages/fdr-sdk/src/navigation/utils/deleteChild.ts @@ -0,0 +1,72 @@ +import { UnreachableCaseError } from "ts-essentials"; +import { FernNavigation } from "../.."; + +/** + * @param parent delete node from this parent (mutable) + * @param node node to delete + * @returns the id of the deleted node or null if the node was not deletable from the parent + */ +export function mutableDeleteChild( + parent: FernNavigation.NavigationNodeParent, + node: FernNavigation.NavigationNode, +): FernNavigation.NodeId | null { + switch (parent.type) { + case "apiPackage": + parent.children = parent.children.filter((child) => child.id !== node.id); + return node.id; + case "apiReference": + parent.children = parent.children.filter((child) => child.id !== node.id); + parent.changelog = parent.changelog?.id === node.id ? undefined : parent.changelog; + return node.id; + case "changelog": + parent.children = parent.children.filter((child) => child.id !== node.id); + return node.id; + case "changelogYear": + parent.children = parent.children.filter((child) => child.id !== node.id); + return node.id; + case "changelogMonth": + parent.children = parent.children.filter((child) => child.id !== node.id); + return node.id; + case "endpointPair": + return null; + case "productgroup": + parent.children = parent.children.filter((child) => child.id !== node.id); + parent.landingPage = parent.landingPage?.id === node.id ? undefined : parent.landingPage; + return node.id; + case "product": + return null; + case "root": + return null; + case "unversioned": + if (node.id === parent.landingPage?.id) { + parent.landingPage = undefined; + return node.id; + } + return null; + case "section": + parent.children = parent.children.filter((child) => child.id !== node.id); + return node.id; + case "sidebarGroup": + parent.children = parent.children.filter((child) => child.id !== node.id); + return node.id; + case "tab": + return null; + case "sidebarRoot": + parent.children = parent.children.filter((child) => child.id !== node.id); + return node.id; + case "tabbed": + parent.children = parent.children.filter((child) => child.id !== node.id); + return node.id; + case "version": + if (node.id === parent.landingPage?.id) { + parent.landingPage = undefined; + return node.id; + } + return null; + case "versioned": + parent.children = parent.children.filter((child) => child.id !== node.id); + return node.id; + default: + throw new UnreachableCaseError(parent); + } +} diff --git a/packages/fdr-sdk/src/navigation/utils/findNode.ts b/packages/fdr-sdk/src/navigation/utils/findNode.ts index 97596d1ce8..f6c1363216 100644 --- a/packages/fdr-sdk/src/navigation/utils/findNode.ts +++ b/packages/fdr-sdk/src/navigation/utils/findNode.ts @@ -13,13 +13,13 @@ export declare namespace Node { interface Found { type: "found"; node: FernNavigation.NavigationNodePage; - parents: FernNavigation.NavigationNode[]; - breadcrumb: FernNavigation.BreadcrumbItem[]; + parents: readonly FernNavigation.NavigationNode[]; + breadcrumb: readonly FernNavigation.BreadcrumbItem[]; root: FernNavigation.RootNode; - versions: FernNavigation.VersionNode[]; + versions: readonly FernNavigation.VersionNode[]; currentVersion: FernNavigation.VersionNode | undefined; currentTab: FernNavigation.TabNode | FernNavigation.ChangelogNode | undefined; - tabs: FernNavigation.TabChild[]; + tabs: readonly FernNavigation.TabChild[]; sidebar: FernNavigation.SidebarRootNode | undefined; apiReference: FernNavigation.ApiReferenceNode | undefined; next: FernNavigation.NavigationNodeNeighbor | undefined; diff --git a/packages/fdr-sdk/src/navigation/utils/followRedirect.ts b/packages/fdr-sdk/src/navigation/utils/followRedirect.ts index 6c260ee107..d29fd5a623 100644 --- a/packages/fdr-sdk/src/navigation/utils/followRedirect.ts +++ b/packages/fdr-sdk/src/navigation/utils/followRedirect.ts @@ -1,4 +1,4 @@ -import visitDiscriminatedUnion from "@fern-ui/core-utils/visitDiscriminatedUnion"; +import { UnreachableCaseError } from "ts-essentials"; import { FernNavigation } from "../.."; export function followRedirect( @@ -7,41 +7,39 @@ export function followRedirect( if (nodeToFollow == null) { return undefined; } - return visitDiscriminatedUnion(nodeToFollow)._visit({ - link: () => undefined, - // leaf nodes - page: (node) => node.slug, - changelog: (node) => node.slug, - changelogYear: (node) => node.slug, - changelogMonth: (node) => node.slug, - changelogEntry: (node) => node.slug, - endpoint: (node) => node.slug, - webSocket: (node) => node.slug, - webhook: (node) => node.slug, - landingPage: (node) => node.slug, - - // nodes with overview - apiPackage: (node) => (node.overviewPageId != null ? node.slug : followRedirects(node.children)), - section: (node) => (node.overviewPageId != null ? node.slug : followRedirects(node.children)), - apiReference: (node) => (node.overviewPageId != null ? node.slug : followRedirects(node.children)), + if (FernNavigation.isPage(nodeToFollow)) { + return nodeToFollow.slug; + } - // version is a special case where it should only consider it's first child (the first version) - product: (node) => followRedirect(node.child), - productgroup: (node) => followRedirect(node.children.filter((node) => !node.hidden)[0]), - versioned: (node) => followRedirect(node.children.filter((node) => !node.hidden)[0]), - unversioned: (node) => followRedirect(node.landingPage ?? node.child), - tabbed: (node) => followRedirects(node.children), - sidebarRoot: (node) => followRedirects(node.children), - endpointPair: (node) => followRedirect(node.nonStream), - root: (node) => followRedirect(node.child), - version: (node) => followRedirect(node.child), - tab: (node) => followRedirect(node.child), - sidebarGroup: (node) => followRedirects(node.children), - }); + switch (nodeToFollow.type) { + case "link": + return undefined; + /** + * Versioned and ProductGroup nodes are special in that they have a default child. + */ + case "productgroup": + case "versioned": + return followRedirect([...nodeToFollow.children].sort(defaultFirst)[0]); + case "apiReference": + case "apiPackage": + case "endpointPair": + case "product": + case "root": + case "section": + case "sidebarRoot": + case "sidebarGroup": + case "tab": + case "tabbed": + case "unversioned": + case "version": + return followRedirects(FernNavigation.getChildren(nodeToFollow)); + default: + throw new UnreachableCaseError(nodeToFollow); + } } -export function followRedirects(nodes: FernNavigation.NavigationNode[]): FernNavigation.Slug | undefined { +export function followRedirects(nodes: readonly FernNavigation.NavigationNode[]): FernNavigation.Slug | undefined { for (const node of nodes) { // skip hidden nodes if (FernNavigation.hasMetadata(node) && node.hidden) { @@ -54,3 +52,11 @@ export function followRedirects(nodes: FernNavigation.NavigationNode[]): FernNav } return; } + +function rank(node: T): number { + return node.default && !node.hidden ? 1 : node.hidden ? -1 : 0; +} + +function defaultFirst(a: T, b: T): number { + return rank(b) - rank(a); +} diff --git a/packages/fdr-sdk/src/navigation/utils/hasChildren.ts b/packages/fdr-sdk/src/navigation/utils/hasChildren.ts index d525d6a0d7..c0d839e8a5 100644 --- a/packages/fdr-sdk/src/navigation/utils/hasChildren.ts +++ b/packages/fdr-sdk/src/navigation/utils/hasChildren.ts @@ -1,7 +1,7 @@ import { UnreachableCaseError } from "ts-essentials"; -import { NavigationNodeWithChildren } from "../versions"; +import { NavigationNodeParent } from "../versions"; -export function hasChildren(node: NavigationNodeWithChildren): boolean { +export function hasChildren(node: NavigationNodeParent): boolean { switch (node.type) { case "apiPackage": return node.children.length > 0; diff --git a/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts b/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts index 6bc95e33ed..4ff3079a41 100644 --- a/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts +++ b/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts @@ -1,144 +1,47 @@ -import { UnreachableCaseError } from "ts-essentials"; +import structuredClone from "core-js-pure/actual/structured-clone"; +import { DeepReadonly } from "ts-essentials"; import { FernNavigation } from "../.."; +import { prunetree } from "../../utils/traversers/prunetree"; +import { mutableDeleteChild } from "./deleteChild"; import { hasChildren } from "./hasChildren"; -import { updatePointsTo } from "./updatePointsTo"; +import { mutableUpdatePointsTo } from "./updatePointsTo"; /** - * * @param root the root node of the navigation tree * @param keep a function that returns true if the node should be kept - * @returns + * @returns a new navigation tree with only the nodes that should be kept */ export function pruneNavigationTree( - root: ROOT, + root: DeepReadonly, keep: (node: FernNavigation.NavigationNode) => boolean, ): ROOT | undefined { - const clone = structuredClone(root); - - // keeps track of deleted nodes to avoid deleting them multiple times - const deleted = new Set(); - - FernNavigation.traverseNavigationLevelOrder(clone, (node, parents) => { - // if the node was already deleted, we don't need to traverse it - if (deleted.has(node.id)) { - return "skip"; - } - - // continue traversal if the node is not to be deleted - if (keep(node)) { - return; - } - - deleteChild(node, parents, deleted).forEach((id) => { - deleted.add(id); - }); + const clone = structuredClone(root) as ROOT; + return mutablePruneNavigationTree(clone, keep); +} - // since the node was deleted, its children are deleted too - // we don't need to traverse them, nor do we need to keep them in the tree. - return "skip"; +function mutablePruneNavigationTree( + root: ROOT, + keep: (node: FernNavigation.NavigationNode) => boolean, +): ROOT | undefined { + const [result, deleted] = prunetree(root, { + predicate: keep, + getChildren: FernNavigation.getChildren, + getPointer: (node) => node.id, + deleter: mutableDeleteChild, + + // after deletion, if the node no longer has any children, we can delete the parent node too + // but only if the parent node is NOT a visitable page + shouldDeleteParent: (parent) => !hasChildren(parent) && !FernNavigation.isPage(parent), }); - if (deleted.has(clone.id)) { + if (result == null) { return undefined; } if (deleted.size > 0) { // since the tree has been pruned, we need to update the pointsTo property - updatePointsTo(clone); - } - - return clone; -} - -/** - * Deletes a child from a parent node - * - * If the parent node cannot be deleted, it will deleted too via recursion. - * - * @param node the child node to delete - * @param parent the parent node - * @param deleted a set of nodes that have already been deleted - * @returns a list of deleted nodes - */ -function deleteChild( - node: FernNavigation.NavigationNode, - parents: readonly FernNavigation.NavigationNodeWithChildren[], - deleted: Set = new Set(), -): FernNavigation.NodeId[] { - const ancestors = [...parents]; - const parent = ancestors.pop(); // the parent node is the last element in the array - if (parent == null) { - return []; - } else if (deleted.has(parent.id)) { - return [node.id]; - } - - const internalDeleted = (() => { - switch (parent.type) { - case "apiPackage": - parent.children = parent.children.filter((child) => child.id !== node.id); - return [node.id]; - case "apiReference": - parent.children = parent.children.filter((child) => child.id !== node.id); - parent.changelog = parent.changelog?.id === node.id ? undefined : parent.changelog; - return [node.id]; - case "changelog": - parent.children = parent.children.filter((child) => child.id !== node.id); - return [node.id]; - case "changelogYear": - parent.children = parent.children.filter((child) => child.id !== node.id); - return [node.id]; - case "changelogMonth": - parent.children = parent.children.filter((child) => child.id !== node.id); - return [node.id]; - case "endpointPair": - return [...deleteChild(parent, ancestors), node.id]; - case "productgroup": - parent.children = parent.children.filter((child) => child.id !== node.id); - parent.landingPage = parent.landingPage?.id === node.id ? undefined : parent.landingPage; - return [node.id]; - case "product": - return [...deleteChild(parent, ancestors), node.id]; - case "root": - return [...deleteChild(parent, ancestors), node.id]; - case "unversioned": - if (node.id === parent.landingPage?.id) { - parent.landingPage = undefined; - return [node.id]; - } - return [...deleteChild(parent, ancestors), node.id]; - case "section": - parent.children = parent.children.filter((child) => child.id !== node.id); - return [node.id]; - case "sidebarGroup": - parent.children = parent.children.filter((child) => child.id !== node.id); - return [node.id]; - case "tab": - return [...deleteChild(parent, ancestors), node.id]; - case "sidebarRoot": - parent.children = parent.children.filter((child) => child.id !== node.id); - return [node.id]; - case "tabbed": - parent.children = parent.children.filter((child) => child.id !== node.id); - return [node.id]; - case "version": - if (node.id === parent.landingPage?.id) { - parent.landingPage = undefined; - return [node.id]; - } - return [...deleteChild(parent, ancestors), node.id]; - case "versioned": - parent.children = parent.children.filter((child) => child.id !== node.id); - return [node.id]; - default: - throw new UnreachableCaseError(parent); - } - })(); - - // after deletion, if the node has no children, we can delete the parent node too - if (!hasChildren(parent) && !internalDeleted.includes(parent.id)) { - return [...deleteChild(parent, ancestors), ...internalDeleted]; + mutableUpdatePointsTo(result); } - return internalDeleted; + return result; } diff --git a/packages/fdr-sdk/src/navigation/utils/pruneVersionNode.ts b/packages/fdr-sdk/src/navigation/utils/pruneVersionNode.ts index 4d4e5910a7..882968c46b 100644 --- a/packages/fdr-sdk/src/navigation/utils/pruneVersionNode.ts +++ b/packages/fdr-sdk/src/navigation/utils/pruneVersionNode.ts @@ -18,7 +18,7 @@ export function pruneVersionNode( if (node == null) { return undefined; } - FernNavigation.traverseNavigation(node, (node) => { + FernNavigation.traverseDF(node, (node) => { if (FernNavigation.hasMetadata(node)) { const newSlug = FernNavigation.toDefaultSlug(node.slug, rootSlug, versionSlug); // children of this node was already pruned diff --git a/packages/fdr-sdk/src/navigation/utils/updatePointsTo.ts b/packages/fdr-sdk/src/navigation/utils/updatePointsTo.ts index e2788db155..3e979adb9a 100644 --- a/packages/fdr-sdk/src/navigation/utils/updatePointsTo.ts +++ b/packages/fdr-sdk/src/navigation/utils/updatePointsTo.ts @@ -1,12 +1,14 @@ -import { NavigationNode, hasPointsTo, traverseNavigationLevelOrder } from "../versions/latest"; +import { FernNavigation } from "../.."; import { followRedirect } from "./followRedirect"; /** + * Uses depth-first traversal to update the pointsTo property of all nodes in the tree. + * * @param input will be mutated */ -export function updatePointsTo(input: NavigationNode): void { - traverseNavigationLevelOrder(input, (node) => { - if (hasPointsTo(node)) { +export function mutableUpdatePointsTo(input: FernNavigation.NavigationNode): void { + FernNavigation.traverseDF(input, (node) => { + if (FernNavigation.hasPointsTo(node)) { node.pointsTo = followRedirect(node); } }); diff --git a/packages/fdr-sdk/src/navigation/versions/latest/NavigationNodeWithChildren.ts b/packages/fdr-sdk/src/navigation/versions/latest/NavigationNodeParent.ts similarity index 56% rename from packages/fdr-sdk/src/navigation/versions/latest/NavigationNodeWithChildren.ts rename to packages/fdr-sdk/src/navigation/versions/latest/NavigationNodeParent.ts index 1c358067e2..8aa1c18b2f 100644 --- a/packages/fdr-sdk/src/navigation/versions/latest/NavigationNodeWithChildren.ts +++ b/packages/fdr-sdk/src/navigation/versions/latest/NavigationNodeParent.ts @@ -1,4 +1,4 @@ import { NavigationNode } from "./NavigationNode"; import { NavigationNodeLeaf } from "./NavigationNodeLeaf"; -export type NavigationNodeWithChildren = Exclude; +export type NavigationNodeParent = Exclude; diff --git a/packages/fdr-sdk/src/navigation/versions/latest/getChildren.ts b/packages/fdr-sdk/src/navigation/versions/latest/getChildren.ts new file mode 100644 index 0000000000..23731be0f0 --- /dev/null +++ b/packages/fdr-sdk/src/navigation/versions/latest/getChildren.ts @@ -0,0 +1,37 @@ +import { UnreachableCaseError } from "ts-essentials"; +import { NavigationNode } from "./NavigationNode"; +import { isLeaf } from "./NavigationNodeLeaf"; + +export function getChildren(node: NavigationNode): readonly NavigationNode[] { + if (isLeaf(node)) { + return []; + } + + switch (node.type) { + case "apiPackage": + case "changelog": + case "changelogMonth": + case "changelogYear": + case "section": + case "sidebarGroup": + case "sidebarRoot": + case "tabbed": + case "versioned": + return node.children; + case "product": + case "root": + case "tab": + return [node.child]; + case "apiReference": + return [...node.children, ...(node.changelog ? [node.changelog] : [])]; + case "endpointPair": + return [node.nonStream, node.stream]; + case "productgroup": + return [...(node.landingPage ? [node.landingPage] : []), ...node.children]; + case "unversioned": + case "version": + return [...(node.landingPage ? [node.landingPage] : []), node.child]; + default: + throw new UnreachableCaseError(node); + } +} diff --git a/packages/fdr-sdk/src/navigation/versions/latest/index.ts b/packages/fdr-sdk/src/navigation/versions/latest/index.ts index 3a8db31d85..5a31808348 100644 --- a/packages/fdr-sdk/src/navigation/versions/latest/index.ts +++ b/packages/fdr-sdk/src/navigation/versions/latest/index.ts @@ -7,12 +7,14 @@ export * from "./NavigationNodeMarkdown"; export * from "./NavigationNodeNeighbor"; export * from "./NavigationNodePage"; export * from "./NavigationNodePageLeaf"; +export * from "./NavigationNodeParent"; export * from "./NavigationNodeSection"; export * from "./NavigationNodeSectionOverview"; -export * from "./NavigationNodeWithChildren"; export * from "./NavigationNodeWithMetadata"; export * from "./NavigationNodeWithRedirect"; +export * from "./getChildren"; export * from "./getPageId"; export * from "./slugjoin"; export * from "./toDefaultSlug"; -export * from "./traverseNavigation"; +export * from "./traverseBF"; +export * from "./traverseDF"; diff --git a/packages/fdr-sdk/src/navigation/versions/latest/traverseBF.ts b/packages/fdr-sdk/src/navigation/versions/latest/traverseBF.ts new file mode 100644 index 0000000000..3c468f03fd --- /dev/null +++ b/packages/fdr-sdk/src/navigation/versions/latest/traverseBF.ts @@ -0,0 +1,14 @@ +import { bfs } from "../../../utils/traversers/bfs"; +import { TraverserVisit } from "../../../utils/traversers/types"; +import { NavigationNode } from "./NavigationNode"; +import { NavigationNodeParent } from "./NavigationNodeParent"; +import { getChildren } from "./getChildren"; + +const SKIP = "skip" as const; + +/** + * Traverse the navigation tree in a depth-first manner (pre-order). + */ +export function traverseBF(node: NavigationNode, visit: TraverserVisit) { + return bfs(node, visit, getChildren); +} diff --git a/packages/fdr-sdk/src/navigation/versions/latest/traverseDF.ts b/packages/fdr-sdk/src/navigation/versions/latest/traverseDF.ts new file mode 100644 index 0000000000..ce1c6ad1f9 --- /dev/null +++ b/packages/fdr-sdk/src/navigation/versions/latest/traverseDF.ts @@ -0,0 +1,12 @@ +import { dfs } from "../../../utils/traversers/dfs"; +import { TraverserVisit } from "../../../utils/traversers/types"; +import { NavigationNode } from "./NavigationNode"; +import { NavigationNodeParent } from "./NavigationNodeParent"; +import { getChildren } from "./getChildren"; + +/** + * Traverse the navigation tree in a depth-first manner (pre-order). + */ +export function traverseDF(node: NavigationNode, visit: TraverserVisit): void { + return dfs(node, visit, getChildren); +} diff --git a/packages/fdr-sdk/src/navigation/versions/latest/traverseNavigation.ts b/packages/fdr-sdk/src/navigation/versions/latest/traverseNavigation.ts deleted file mode 100644 index 22b5bc2660..0000000000 --- a/packages/fdr-sdk/src/navigation/versions/latest/traverseNavigation.ts +++ /dev/null @@ -1,221 +0,0 @@ -import visitDiscriminatedUnion from "@fern-ui/core-utils/visitDiscriminatedUnion"; -import { noop } from "ts-essentials"; -import { NavigationNode } from "./NavigationNode"; -import { isLeaf } from "./NavigationNodeLeaf"; -import { NavigationNodeWithChildren } from "./NavigationNodeWithChildren"; - -const SKIP = "skip" as const; -const STOP = false as const; - -/** - * Traverse the navigation tree in a depth-first manner (pre-order). - */ -export function traverseNavigation( - node: NavigationNode, - visit: ( - node: NavigationNode, - index: number | undefined, - parents: NavigationNodeWithChildren[], - ) => boolean | typeof SKIP | void, -): void { - function internalChildrenTraverser(nodes: NavigationNode[], parents: NavigationNodeWithChildren[]): boolean | void { - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; - if (node == null) { - throw new Error(`Failed to index into nodes. Index: ${i} Length: ${nodes.length}`); - } - const result = internalTraverser(node, i, parents); - if (result === STOP) { - return STOP; - } - } - return; - } - function internalTraverser( - node: NavigationNode, - index: number | undefined, - parents: NavigationNodeWithChildren[], - ): boolean | void { - const v = visit(node, index, parents); - if (v === SKIP) { - return; - } else if (v === STOP) { - return STOP; - } - return visitDiscriminatedUnion(node)._visit({ - root: (root) => internalTraverser(root.child, undefined, [...parents, root]), - product: (product) => internalTraverser(product.child, undefined, [...parents, product]), - productgroup: (productgroup) => { - if (productgroup.landingPage != null) { - const result = internalTraverser(productgroup.landingPage, undefined, [...parents, productgroup]); - if (result === STOP) { - return STOP; - } - } - return internalChildrenTraverser(productgroup.children, [...parents, productgroup]); - }, - versioned: (versioned) => internalChildrenTraverser(versioned.children, [...parents, versioned]), - tabbed: (tabbed) => internalChildrenTraverser(tabbed.children, [...parents, tabbed]), - sidebarRoot: (sidebar) => internalChildrenTraverser(sidebar.children, [...parents, sidebar]), - sidebarGroup: (sidebarGroup) => - internalChildrenTraverser(sidebarGroup.children, [...parents, sidebarGroup]), - version: (version) => { - if (version.landingPage != null) { - const result = internalTraverser(version.landingPage, undefined, [...parents, version]); - if (result === STOP) { - return STOP; - } - } - return internalTraverser(version.child, undefined, [...parents, version]); - }, - tab: (tab) => internalTraverser(tab.child, undefined, [...parents, tab]), - link: noop, - page: noop, - landingPage: noop, - section: (section) => internalChildrenTraverser(section.children, [...parents, section]), - apiReference: (apiReference) => { - const result = internalChildrenTraverser(apiReference.children, [...parents, apiReference]); - if (result === STOP) { - return STOP; - } - if (apiReference.changelog != null) { - return internalTraverser(apiReference.changelog, undefined, [...parents, apiReference]); - } - }, - changelog: (changelog) => internalChildrenTraverser(changelog.children, [...parents, changelog]), - changelogYear: (changelogYear) => - internalChildrenTraverser(changelogYear.children, [...parents, changelogYear]), - changelogMonth: (changelogMonth) => - internalChildrenTraverser(changelogMonth.children, [...parents, changelogMonth]), - changelogEntry: noop, - endpoint: noop, - webSocket: noop, - webhook: noop, - apiPackage: (apiPackage) => internalChildrenTraverser(apiPackage.children, [...parents, apiPackage]), - endpointPair: (endpointPair) => { - const result = internalTraverser(endpointPair.nonStream, undefined, [...parents, endpointPair]); - if (result === STOP) { - return STOP; - } - return internalTraverser(endpointPair.stream, undefined, [...parents, endpointPair]); - }, - unversioned: (unversioned) => { - if (unversioned.landingPage != null) { - const result = internalTraverser(unversioned.landingPage, undefined, [...parents, unversioned]); - if (result === STOP) { - return STOP; - } - } - - return internalTraverser(unversioned.child, undefined, [...parents, unversioned]); - }, - }); - } - internalTraverser(node, undefined, []); -} - -export function traverseNavigationLevelOrder( - node: NavigationNode, - visit: (node: NavigationNode, parent: NavigationNodeWithChildren[]) => typeof SKIP | void, -) { - const queue: [NavigationNode, NavigationNodeWithChildren[]][] = [[node, []]]; - while (queue.length > 0) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const [node, parents] = queue.shift()!; - - const result = visit(node, parents); - if (result === SKIP) { - continue; - } - - if (isLeaf(node)) { - continue; - } - - visitDiscriminatedUnion(node)._visit({ - root: (root) => queue.push([root.child, [...parents, root]]), - product: (product) => queue.push([product.child, [...parents, product]]), - productgroup: (productgroup) => { - if (productgroup.landingPage) { - queue.push([productgroup.landingPage, [...parents, productgroup]]); - } - - for (const child of productgroup.children) { - queue.push([child, [...parents, productgroup]]); - } - }, - versioned: (versioned) => { - for (const child of versioned.children) { - queue.push([child, [...parents, versioned]]); - } - }, - tabbed: (tabbed) => { - for (const child of tabbed.children) { - queue.push([child, [...parents, tabbed]]); - } - }, - sidebarRoot: (sidebar) => { - for (const child of sidebar.children) { - queue.push([child, [...parents, sidebar]]); - } - }, - sidebarGroup: (sidebarGroup) => { - for (const child of sidebarGroup.children) { - queue.push([child, [...parents, sidebarGroup]]); - } - }, - version: (version) => { - if (version.landingPage != null) { - queue.push([version.landingPage, [...parents, version]]); - } - queue.push([version.child, [...parents, version]]); - }, - tab: (tab) => { - queue.push([tab.child, [...parents, tab]]); - }, - section: (section) => { - for (const child of section.children) { - queue.push([child, [...parents, section]]); - } - }, - apiReference: (apiReference) => { - for (const child of apiReference.children) { - queue.push([child, [...parents, apiReference]]); - } - if (apiReference.changelog != null) { - queue.push([apiReference.changelog, [...parents, apiReference]]); - } - }, - changelog: (changelog) => { - for (const child of changelog.children) { - queue.push([child, [...parents, changelog]]); - } - }, - changelogYear: (changelogYear) => { - for (const child of changelogYear.children) { - queue.push([child, [...parents, changelogYear]]); - } - }, - changelogMonth: (changelogMonth) => { - for (const child of changelogMonth.children) { - queue.push([child, [...parents, changelogMonth]]); - } - }, - apiPackage: (apiPackage) => { - for (const child of apiPackage.children) { - queue.push([child, [...parents, apiPackage]]); - } - }, - endpointPair: (endpointPair) => { - queue.push([endpointPair.nonStream, [...parents, endpointPair]]); - queue.push([endpointPair.stream, [...parents, endpointPair]]); - }, - unversioned: (unversioned) => { - if (unversioned.landingPage != null) { - queue.push([unversioned.landingPage, [...parents, unversioned]]); - } - queue.push([unversioned.child, [...parents, unversioned]]); - }, - }); - } -} diff --git a/packages/fdr-sdk/src/utils/traversers/__test__/bfs.test.ts b/packages/fdr-sdk/src/utils/traversers/__test__/bfs.test.ts new file mode 100644 index 0000000000..88b88955cf --- /dev/null +++ b/packages/fdr-sdk/src/utils/traversers/__test__/bfs.test.ts @@ -0,0 +1,71 @@ +import { bfs } from "../bfs"; +import { FIXTURE } from "./fixture"; + +describe("bfs", () => { + it("should traverse the tree in breadth-first order", () => { + const visited: [number, number[]][] = []; + + bfs( + FIXTURE, + (n, p) => { + visited.push([n.id, p.map((n) => n.id)]); + }, + (n) => n.children, + ); + + expect(visited).toStrictEqual([ + [0, []], + [1, [0]], + [6, [0]], + [2, [0, 1]], + [4, [0, 1]], + [7, [0, 6]], + [3, [0, 1, 2]], + [5, [0, 1, 4]], + ]); + }); + + it("should skip nodes if the visitor returns 'skip'", () => { + const visited: [number, number[]][] = []; + + bfs( + FIXTURE, + (n, p) => { + visited.push([n.id, p.map((n) => n.id)]); + if (n.id === 1) { + return "skip"; + } + return; + }, + (p) => p.children, + ); + + expect(visited).toStrictEqual([ + [0, []], + [1, [0]], + [6, [0]], + [7, [0, 6]], + ]); + }); + + it("should stop traversal if the visitor returns false", () => { + const visited: [number, number[]][] = []; + + bfs( + FIXTURE, + (n, p) => { + visited.push([n.id, p.map((n) => n.id)]); + if (n.id === 1) { + return false; + } + return; + }, + (p) => p.children, + ); + + expect(visited).toStrictEqual([ + [0, []], + [1, [0]], + ]); + }); +}); diff --git a/packages/fdr-sdk/src/utils/traversers/__test__/dfs.test.ts b/packages/fdr-sdk/src/utils/traversers/__test__/dfs.test.ts new file mode 100644 index 0000000000..7be1f41173 --- /dev/null +++ b/packages/fdr-sdk/src/utils/traversers/__test__/dfs.test.ts @@ -0,0 +1,69 @@ +import { dfs } from "../dfs"; +import { FIXTURE } from "./fixture"; + +describe("dfs", () => { + it("should traverse the tree in breadth-first order", () => { + const visited: [number, number[]][] = []; + + dfs( + FIXTURE, + (n, p) => { + visited.push([n.id, p.map((n) => n.id)]); + }, + (n) => n.children, + ); + + expect(visited).toStrictEqual([ + [0, []], + [1, [0]], + [2, [0, 1]], + [3, [0, 1, 2]], + [4, [0, 1]], + [5, [0, 1, 4]], + [6, [0]], + [7, [0, 6]], + ]); + }); + + it("should skip nodes if the visitor returns 'skip'", () => { + const visited: [number, number[]][] = []; + dfs( + FIXTURE, + (n, p) => { + visited.push([n.id, p.map((n) => n.id)]); + if (n.id === 1) { + return "skip"; + } + return; + }, + (p) => p.children, + ); + + expect(visited).toStrictEqual([ + [0, []], + [1, [0]], + [6, [0]], + [7, [0, 6]], + ]); + }); + + it("should stop traversal if the visitor returns false", () => { + const visited: [number, number[]][] = []; + dfs( + FIXTURE, + (n, p) => { + visited.push([n.id, p.map((n) => n.id)]); + if (n.id === 1) { + return false; + } + return; + }, + (p) => p.children, + ); + + expect(visited).toStrictEqual([ + [0, []], + [1, [0]], + ]); + }); +}); diff --git a/packages/fdr-sdk/src/utils/traversers/__test__/fixture.ts b/packages/fdr-sdk/src/utils/traversers/__test__/fixture.ts new file mode 100644 index 0000000000..d498e55f8d --- /dev/null +++ b/packages/fdr-sdk/src/utils/traversers/__test__/fixture.ts @@ -0,0 +1,42 @@ +export interface Record { + id: number; + children: Record[]; +} + +export const FIXTURE: Record = { + id: 0, + children: [ + { + id: 1, + children: [ + { + id: 2, + children: [ + { + id: 3, + children: [], + }, + ], + }, + { + id: 4, + children: [ + { + id: 5, + children: [], + }, + ], + }, + ], + }, + { + id: 6, + children: [ + { + id: 7, + children: [], + }, + ], + }, + ], +}; diff --git a/packages/fdr-sdk/src/utils/traversers/__test__/prunetree.test.ts b/packages/fdr-sdk/src/utils/traversers/__test__/prunetree.test.ts new file mode 100644 index 0000000000..03604c4454 --- /dev/null +++ b/packages/fdr-sdk/src/utils/traversers/__test__/prunetree.test.ts @@ -0,0 +1,95 @@ +import { prunetree } from "../prunetree"; +import { FIXTURE, Record } from "./fixture"; + +describe("prunetree", () => { + it("should return the same tree if the predicate returns true for all nodes", () => { + const [pruned] = prunetree(structuredClone(FIXTURE), { + predicate: () => { + return true; + }, + getChildren: (node) => node.children, + deleter: (parent, child) => { + parent.children = parent.children.filter((c) => c.id !== child.id); + return child.id; + }, + getPointer: (node) => node.id, + }); + expect(pruned).toStrictEqual(FIXTURE); + }); + + it("should return undefined if the predicate returns false for all nodes", () => { + const [pruned] = prunetree(structuredClone(FIXTURE), { + predicate: () => { + return false; + }, + getChildren: (node) => node.children, + deleter: (parent, child) => { + parent.children = parent.children.filter((c) => c.id !== child.id); + return child.id; + }, + getPointer: (node) => node.id, + }); + expect(pruned).toBeUndefined(); + }); + + it("should prune the tree if the predicate returns false for some nodes", () => { + const [pruned] = prunetree(structuredClone(FIXTURE), { + predicate: (node) => { + return node.id !== 1; + }, + getChildren: (node) => node.children, + deleter: (parent, child) => { + parent.children = parent.children.filter((c) => c.id !== child.id); + return child.id; + }, + getPointer: (node) => node.id, + }); + expect(pruned).toStrictEqual({ + id: 0, + children: [ + { + id: 6, + children: [ + { + id: 7, + children: [], + }, + ], + }, + ], + }); + }); + + it("should prune parents that don't have children, but not leaf nodes", () => { + const [pruned] = prunetree(structuredClone(FIXTURE), { + predicate: (node) => { + return node.id !== 7 && node.id !== 3; + }, + getChildren: (node) => node.children, + deleter: (parent, child) => { + parent.children = parent.children.filter((c) => c.id !== child.id); + return child.id; + }, + getPointer: (node) => node.id, + }); + expect(pruned).toStrictEqual({ + id: 0, + children: [ + { + id: 1, + children: [ + { + id: 4, + children: [ + { + id: 5, + children: [], + }, + ], + }, + ], + }, + ], + }); + }); +}); diff --git a/packages/fdr-sdk/src/utils/traversers/bfs.ts b/packages/fdr-sdk/src/utils/traversers/bfs.ts new file mode 100644 index 0000000000..ff82789b41 --- /dev/null +++ b/packages/fdr-sdk/src/utils/traversers/bfs.ts @@ -0,0 +1,27 @@ +import { SKIP, STOP, TraverserGetChildren, TraverserVisit } from "./types"; + +export function bfs( + root: N, + visit: TraverserVisit, + getChildren: TraverserGetChildren, + isParent: (node: N) => node is P = (node): node is P => true, +): void { + const queue: [N, P[]][] = [[root, []]]; + while (queue.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const [node, parents] = queue.shift()!; + const next = visit(node, parents); + + if (next === SKIP) { + continue; + } else if (next === STOP) { + return; + } + + if (isParent(node)) { + for (const child of [...getChildren(node)]) { + queue.push([child, [...parents, node]]); + } + } + } +} diff --git a/packages/fdr-sdk/src/utils/traversers/dfs.ts b/packages/fdr-sdk/src/utils/traversers/dfs.ts new file mode 100644 index 0000000000..9639a948b8 --- /dev/null +++ b/packages/fdr-sdk/src/utils/traversers/dfs.ts @@ -0,0 +1,26 @@ +import { SKIP, STOP, TraverserGetChildren, TraverserVisit } from "./types"; + +export function dfs( + root: N, + visit: TraverserVisit, + getChildren: TraverserGetChildren, + isParent: (node: N) => node is P = (node): node is P => true, +): void { + const stack: [N, P[]][] = [[root, []]]; + while (stack.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const [node, parents] = stack.pop()!; + const next = visit(node, parents); + if (next === SKIP) { + continue; + } else if (next === STOP) { + return; + } + + if (isParent(node)) { + for (const child of [...getChildren(node)].reverse()) { + stack.push([child, [...parents, node]]); + } + } + } +} diff --git a/packages/fdr-sdk/src/utils/traversers/prunetree.ts b/packages/fdr-sdk/src/utils/traversers/prunetree.ts new file mode 100644 index 0000000000..73b2937649 --- /dev/null +++ b/packages/fdr-sdk/src/utils/traversers/prunetree.ts @@ -0,0 +1,121 @@ +import { bfs } from "./bfs"; + +interface PruneTreeOptions { + /** + * @param node the node to check + * @returns **false** if the node SHOULD be deleted + */ + predicate: (node: NODE) => boolean; + getChildren: (node: PARENT) => readonly NODE[]; + + /** + * @param parent the parent node + * @param child the child that should be deleted + * @returns the pointer to the child node, or **null** if the child cannot be deleted + */ + deleter: (parent: PARENT, child: NODE) => POINTER | null; + + /** + * After the child is deleted, we can check if the parent should be deleted too, + * e.g. if the parent has no children left. + * + * @param parent node + * @returns **true** if the node should be deleted + * @default parent => getChildren(parent).length === 0 + */ + shouldDeleteParent?: (parent: PARENT) => boolean; + + /** + * If there are circular references, we can use this function to get a unique identifier for the node. + * + * @param node + * @returns the unique identifier for the node + * @default node => node as unknown as POINTER (the reference itself is used as the identifier) + */ + getPointer?: (node: NODE) => POINTER; +} + +export function prunetree( + root: ROOT, + opts: PruneTreeOptions, +): [result: ROOT | undefined, deleted: ReadonlySet] { + const { + predicate, + getChildren, + deleter, + shouldDeleteParent = (parent) => getChildren(parent).length === 0, + getPointer = (node) => node as unknown as POINTER, + } = opts; + + const deleted = new Set(); + + const visitor = (node: NODE, parents: readonly PARENT[]) => { + // if the node or its parents was already deleted, we don't need to traverse it + if ([...parents, node].some((parent) => deleted.has(getPointer(parent)))) { + return "skip"; + } + + // continue traversal if the node is not to be deleted + if (predicate(node)) { + return; + } + + deleteChildAndMaybeParent(node, parents, { + deleter, + shouldDeleteParent, + getPointer, + }).forEach((id) => { + deleted.add(id); + }); + + // since the node was deleted, its children are deleted too + // we don't need to traverse them, nor do we need to keep them in the tree. + // note: the deleted set will NOT contain the children of this node + return "skip"; + }; + + bfs(root, visitor, getChildren); + + if (deleted.has(getPointer(root))) { + return [undefined, deleted]; + } + + return [root, deleted]; +} + +interface DeleteChildOptions { + deleter: (parent: PARENT, child: NODE) => POINTER | null; + shouldDeleteParent: (parent: PARENT) => boolean; + getPointer: (node: NODE) => POINTER; +} + +function deleteChildAndMaybeParent( + node: NODE, + parents: readonly PARENT[], + opts: DeleteChildOptions, +): POINTER[] { + const { deleter, shouldDeleteParent, getPointer } = opts; + + const ancestors = [...parents]; + const parent = ancestors.pop(); + + // if the parent is the root, we cannot delete it here + // so we mark it as deleted and the parent function will be responsible for deleting it + if (parent == null) { + return [getPointer(node)]; + } + + const deleted = deleter(parent, node); + + // if the node was not deletable, then we need to delete the parent too + if (deleted == null) { + return [getPointer(node), ...deleteChildAndMaybeParent(parent, ancestors, opts)]; + } + + // traverse up the tree and delete the parent if necessary + if (shouldDeleteParent(parent)) { + return [getPointer(node), deleted, ...deleteChildAndMaybeParent(parent, ancestors, opts)]; + } + + return [getPointer(node), deleted]; +} diff --git a/packages/fdr-sdk/src/utils/traversers/types.ts b/packages/fdr-sdk/src/utils/traversers/types.ts new file mode 100644 index 0000000000..5ca0f697d4 --- /dev/null +++ b/packages/fdr-sdk/src/utils/traversers/types.ts @@ -0,0 +1,8 @@ +export const SKIP = "skip" as const; +export const STOP = false; +export const CONTINUE = true; + +export type Next = typeof CONTINUE | typeof SKIP | typeof STOP | void; + +export type TraverserVisit = (node: N, parents: readonly P[]) => Next; +export type TraverserGetChildren = (parent: P) => readonly N[]; diff --git a/packages/ui/app/src/atoms/sidebar.ts b/packages/ui/app/src/atoms/sidebar.ts index 8bafd0280d..05fdf9f707 100644 --- a/packages/ui/app/src/atoms/sidebar.ts +++ b/packages/ui/app/src/atoms/sidebar.ts @@ -24,7 +24,7 @@ export const SIDEBAR_CHILD_TO_PARENTS_MAP_ATOM = atom((get) => { return childToParentsMap; } - FernNavigation.traverseNavigation(sidebar, (node, _index, parents) => { + FernNavigation.traverseDF(sidebar, (node, parents) => { childToParentsMap.set( node.id, parents.map((p) => p.id), @@ -69,7 +69,7 @@ const INTERNAL_EXPANDED_SIDEBAR_NODES_ATOM = atomWithDefault<{ // the following was commented out because FDR stores `collapsed: false` by default. Another solution is needed. // const sidebar = get(SIDEBAR_ROOT_NODE_ATOM); // if (sidebar != null) { - // FernNavigation.traverseNavigation(sidebar, (node) => { + // FernNavigation.traverseDF(sidebar, (node) => { // // TODO: check for api reference, etc. // if (node.type === "section" && node.collapsed === false) { // expandedNodes.add(node.id); diff --git a/packages/ui/app/src/util/resolveDocsContent.ts b/packages/ui/app/src/util/resolveDocsContent.ts index 1150622e39..e0e4b9cb53 100644 --- a/packages/ui/app/src/util/resolveDocsContent.ts +++ b/packages/ui/app/src/util/resolveDocsContent.ts @@ -72,7 +72,7 @@ export async function resolveDocsContent({ if (node.type === "changelog") { const pageIds = new Set(); - FernNavigation.traverseNavigation(node, (n) => { + FernNavigation.traverseDF(node, (n) => { if (FernNavigation.hasMarkdown(n)) { const pageId = FernNavigation.getPageId(n); if (pageId != null) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0e13ee1ce..b9a56ee13d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -446,7 +446,7 @@ importers: version: 3.3.2 simple-git: specifier: ^3.24.0 - version: 3.24.0 + version: 3.24.0(supports-color@8.1.1) stylelint: specifier: ^16.1.0 version: 16.5.0(typescript@5.4.3) @@ -817,6 +817,9 @@ importers: '@fern-ui/core-utils': specifier: workspace:* version: link:../commons/core-utils + core-js-pure: + specifier: ^3.38.1 + version: 3.38.1 dayjs: specifier: ^1.11.11 version: 1.11.11 @@ -2657,7 +2660,7 @@ importers: version: 3.21.0(serverless@3.38.0) simple-git: specifier: ^3.24.0 - version: 3.24.0 + version: 3.24.0(supports-color@8.1.1) tmp-promise: specifier: ^3.0.3 version: 3.0.3 @@ -9489,6 +9492,9 @@ packages: core-js-pure@3.37.0: resolution: {integrity: sha512-d3BrpyFr5eD4KcbRvQ3FTUx/KWmaDesr7+a3+1+P46IUnNoEt+oiLijPINZMEon7w9oGkIINWxrBAU9DEciwFQ==} + core-js-pure@3.38.1: + resolution: {integrity: sha512-BY8Etc1FZqdw1glX0XNOq2FDwfrg/VGqoZOZCdaL+UmdaqDwQwYXkMJT4t6In+zfEfOJDcM9T0KdbBeJg8KKCQ==} + core-js@3.37.0: resolution: {integrity: sha512-fu5vHevQ8ZG4og+LXug8ulUtVxjOcEYvifJr7L5Bfq9GOztVqsKd9/59hUk2ZSbCrS3BqUr3EpaYGIYzq7g3Ug==} @@ -17681,7 +17687,7 @@ snapshots: '@babel/traverse': 7.24.5 '@babel/types': 7.24.5 convert-source-map: 2.0.0 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -18480,7 +18486,7 @@ snapshots: '@babel/helper-split-export-declaration': 7.24.5 '@babel/parser': 7.24.5 '@babel/types': 7.24.5 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -18495,7 +18501,7 @@ snapshots: '@babel/helper-split-export-declaration': 7.24.5 '@babel/parser': 7.24.5 '@babel/types': 7.24.5 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -18719,7 +18725,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.12.6 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) espree: 9.6.1 globals: 13.24.0 ignore: 5.3.1 @@ -18982,7 +18988,7 @@ snapshots: '@humanwhocodes/config-array@0.11.14': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -19450,12 +19456,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.4.15 - '@kwsites/file-exists@1.1.1': - dependencies: - debug: 4.3.4(supports-color@5.5.0) - transitivePeerDependencies: - - supports-color - '@kwsites/file-exists@1.1.1(supports-color@8.1.1)': dependencies: debug: 4.3.4(supports-color@8.1.1) @@ -24171,7 +24171,7 @@ snapshots: '@typescript-eslint/type-utils': 7.3.1(eslint@8.57.0)(typescript@5.4.3) '@typescript-eslint/utils': 7.3.1(eslint@8.57.0)(typescript@5.4.3) '@typescript-eslint/visitor-keys': 7.3.1 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) eslint: 8.57.0 graphemer: 1.4.0 ignore: 5.3.1 @@ -24189,7 +24189,7 @@ snapshots: '@typescript-eslint/types': 7.17.0 '@typescript-eslint/typescript-estree': 7.17.0(typescript@5.4.3) '@typescript-eslint/visitor-keys': 7.17.0 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) eslint: 8.57.0 optionalDependencies: typescript: 5.4.3 @@ -24202,7 +24202,7 @@ snapshots: '@typescript-eslint/types': 7.2.0 '@typescript-eslint/typescript-estree': 7.2.0(typescript@5.4.3) '@typescript-eslint/visitor-keys': 7.2.0 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) eslint: 8.57.0 optionalDependencies: typescript: 5.4.3 @@ -24215,7 +24215,7 @@ snapshots: '@typescript-eslint/types': 7.3.1 '@typescript-eslint/typescript-estree': 7.3.1(typescript@5.4.3) '@typescript-eslint/visitor-keys': 7.3.1 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) eslint: 8.57.0 optionalDependencies: typescript: 5.4.3 @@ -24268,7 +24268,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 7.3.1(typescript@5.4.3) '@typescript-eslint/utils': 7.3.1(eslint@8.57.0)(typescript@5.4.3) - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) eslint: 8.57.0 ts-api-utils: 1.3.0(typescript@5.4.3) optionalDependencies: @@ -24351,7 +24351,7 @@ snapshots: dependencies: '@typescript-eslint/types': 7.3.1 '@typescript-eslint/visitor-keys': 7.3.1 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.3 @@ -25787,7 +25787,7 @@ snapshots: agent-base@7.1.1: dependencies: - debug: 4.3.6(supports-color@8.1.1) + debug: 4.3.7 transitivePeerDependencies: - supports-color @@ -26661,7 +26661,7 @@ snapshots: chokidar@3.6.0: dependencies: anymatch: 3.1.3 - braces: 3.0.2 + braces: 3.0.3 glob-parent: 5.1.2 is-binary-path: 2.1.0 is-glob: 4.0.3 @@ -26983,6 +26983,8 @@ snapshots: core-js-pure@3.37.0: {} + core-js-pure@3.38.1: {} + core-js@3.37.0: {} core-util-is@1.0.3: {} @@ -27435,7 +27437,7 @@ snapshots: callsite: 1.0.0 camelcase: 6.3.0 cosmiconfig: 7.1.0 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) deps-regex: 0.2.0 findup-sync: 5.0.0 ignore: 5.3.1 @@ -27925,7 +27927,7 @@ snapshots: eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0): dependencies: - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) enhanced-resolve: 5.16.1 eslint: 8.57.0 eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0) @@ -28139,7 +28141,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.3 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -29366,7 +29368,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.1 - debug: 4.3.6(supports-color@8.1.1) + debug: 4.3.7 transitivePeerDependencies: - supports-color @@ -29406,7 +29408,7 @@ snapshots: https-proxy-agent@7.0.5: dependencies: agent-base: 7.1.1 - debug: 4.3.6(supports-color@8.1.1) + debug: 4.3.7 transitivePeerDependencies: - supports-color @@ -29571,7 +29573,7 @@ snapshots: dependencies: '@ioredis/commands': 1.2.0 cluster-key-slot: 1.1.2 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) denque: 2.1.0 lodash.defaults: 4.2.0 lodash.isarguments: 3.1.0 @@ -30534,7 +30536,7 @@ snapshots: dependencies: chalk: 5.3.0 commander: 11.0.0 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) execa: 7.2.0 lilconfig: 2.1.0 listr2: 6.6.1 @@ -33972,14 +33974,6 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 - simple-git@3.24.0: - dependencies: - '@kwsites/file-exists': 1.1.1 - '@kwsites/promise-deferred': 1.1.1 - debug: 4.3.4(supports-color@5.5.0) - transitivePeerDependencies: - - supports-color - simple-git@3.24.0(supports-color@8.1.1): dependencies: '@kwsites/file-exists': 1.1.1(supports-color@8.1.1) @@ -34420,7 +34414,7 @@ snapshots: cosmiconfig: 9.0.0(typescript@5.4.3) css-functions-list: 3.2.2 css-tree: 2.3.1 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) fast-glob: 3.3.2 fastest-levenshtein: 1.0.16 file-entry-cache: 8.0.0 @@ -34458,7 +34452,7 @@ snapshots: stylus@0.62.0: dependencies: '@adobe/css-tools': 4.3.3 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.7 glob: 7.2.3 sax: 1.3.0 source-map: 0.7.4 @@ -35055,7 +35049,7 @@ snapshots: bundle-require: 4.1.0(esbuild@0.20.2) cac: 6.7.14 chokidar: 3.6.0 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) esbuild: 0.20.2 execa: 5.1.1 globby: 11.1.0 @@ -35577,7 +35571,7 @@ snapshots: vite-node@1.6.0(@types/node@18.19.33)(less@4.2.0)(sass@1.77.0)(stylus@0.62.0)(terser@5.31.0): dependencies: cac: 6.7.14 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) pathe: 1.1.2 picocolors: 1.0.0 vite: 5.2.11(@types/node@18.19.33)(less@4.2.0)(sass@1.77.0)(stylus@0.62.0)(terser@5.31.0) @@ -35594,7 +35588,7 @@ snapshots: vite-node@1.6.0(@types/node@22.5.5)(less@4.2.0)(sass@1.77.0)(stylus@0.62.0)(terser@5.31.0): dependencies: cac: 6.7.14 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) pathe: 1.1.2 picocolors: 1.0.0 vite: 5.2.11(@types/node@22.5.5)(less@4.2.0)(sass@1.77.0)(stylus@0.62.0)(terser@5.31.0) @@ -35718,7 +35712,7 @@ snapshots: '@vitest/utils': 1.6.0 acorn-walk: 8.3.2 chai: 4.4.1 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) execa: 8.0.1 local-pkg: 0.5.0 magic-string: 0.30.10 @@ -35753,7 +35747,7 @@ snapshots: '@vitest/utils': 1.6.0 acorn-walk: 8.3.2 chai: 4.4.1 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) execa: 8.0.1 local-pkg: 0.5.0 magic-string: 0.30.10