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..b4fd0364e3 100644 --- a/packages/fdr-sdk/package.json +++ b/packages/fdr-sdk/package.json @@ -32,6 +32,7 @@ }, "dependencies": { "@fern-ui/core-utils": "workspace:*", + "@ungap/structured-clone": "^1.2.0", "dayjs": "^1.11.11", "fast-deep-equal": "^3.1.3", "form-data": "4.0.0", @@ -57,6 +58,7 @@ "@types/qs": "6.9.14", "@types/tinycolor2": "^1.4.6", "@types/title": "^3.4.3", + "@types/ungap__structured-clone": "^1.2.0", "eslint": "^8.56.0", "prettier": "^3.3.2", "typescript": "5.4.3", 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..2ec3a34327 --- /dev/null +++ b/packages/fdr-sdk/src/navigation/utils/__test__/pruneNavigationTree.test.ts @@ -0,0 +1,358 @@ +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: FernNavigation.Slug("root/page"), + }); + }); + + 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: FernNavigation.Slug("root/page"), + }; + + const result = pruneNavigationTree(root, (node) => node.id !== FernNavigation.NodeId("page")); + + expect(result).toBeUndefined(); + }); + + it("should not prune section children even if section itself is pruned", () => { + const root: FernNavigation.NavigationNode = { + type: "section", + id: FernNavigation.NodeId("root"), + slug: FernNavigation.Slug("root"), + title: "Root", + overviewPageId: FernNavigation.PageId("overview.mdx"), // this is a visitable page + 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, + noindex: undefined, + pointsTo: undefined, + }; + + const result = pruneNavigationTree(root, (node) => node.id !== "root"); + + // structuredClone should duplicate the object + expect(result === root).toBe(false); + + expect(result).toStrictEqual({ + type: "section", + id: FernNavigation.NodeId("root"), + slug: FernNavigation.Slug("root"), + overviewPageId: undefined, // this should be deleted + 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, + noindex: undefined, + pointsTo: FernNavigation.Slug("root/page"), + }); + }); + + it("should not prune section even if children are pruned", () => { + const root: FernNavigation.NavigationNode = { + type: "section", + id: FernNavigation.NodeId("root"), + slug: FernNavigation.Slug("root"), + title: "Root", + overviewPageId: FernNavigation.PageId("overview.mdx"), // this is a visitable page + 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, + noindex: undefined, + pointsTo: undefined, + }; + + const result = pruneNavigationTree(root, (node) => node.id !== "page"); + + // structuredClone should duplicate the object + expect(result === root).toBe(false); + + expect(result).toStrictEqual({ + type: "section", + id: FernNavigation.NodeId("root"), + slug: FernNavigation.Slug("root"), + overviewPageId: FernNavigation.PageId("overview.mdx"), // this is a visitable page + title: "Root", + children: [], // children is empty, but the section is still there because it has an overview page + collapsed: undefined, + canonicalSlug: undefined, + icon: undefined, + hidden: undefined, + noindex: undefined, + pointsTo: undefined, + }); + }); + + it("should not prune non-leaf nodes", () => { + const root: FernNavigation.NavigationNode = { + type: "section", + id: FernNavigation.NodeId("root"), + slug: FernNavigation.Slug("root"), + title: "Root", + overviewPageId: undefined, + 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, + noindex: undefined, + pointsTo: undefined, + }; + + const result = pruneNavigationTree(root, (node) => node.id !== "root"); + + // structuredClone should duplicate the object + expect(result === root).toBe(false); + + expect(result).toStrictEqual({ + type: "section", + id: FernNavigation.NodeId("root"), + slug: FernNavigation.Slug("root"), + overviewPageId: undefined, // this should be deleted + 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, + noindex: undefined, + pointsTo: FernNavigation.Slug("root/page"), + }); + }); + + it("should delete leaf node and its parent if no siblings left", () => { + const root: FernNavigation.NavigationNode = { + type: "section", + id: FernNavigation.NodeId("root"), + slug: FernNavigation.Slug("root"), + title: "Root", + overviewPageId: undefined, + children: [ + { + type: "section", + id: FernNavigation.NodeId("section2"), + slug: FernNavigation.Slug("root/section2"), + title: "Section 2", + overviewPageId: undefined, + children: [ + { + type: "page", + id: FernNavigation.NodeId("page1"), + slug: FernNavigation.Slug("root/section2/page"), + title: "Page", + pageId: FernNavigation.PageId("page.mdx"), + canonicalSlug: undefined, + icon: undefined, + hidden: undefined, + noindex: undefined, + }, + ], + collapsed: undefined, + canonicalSlug: undefined, + icon: undefined, + hidden: undefined, + noindex: undefined, + pointsTo: undefined, + }, + { + type: "page", + id: FernNavigation.NodeId("page2"), + 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, + noindex: undefined, + pointsTo: undefined, + }; + + const result = pruneNavigationTree(root, (node) => node.id !== "page1"); + + // structuredClone should duplicate the object + expect(result === root).toBe(false); + + expect(result).toStrictEqual({ + type: "section", + id: FernNavigation.NodeId("root"), + slug: FernNavigation.Slug("root"), + overviewPageId: undefined, // this should be deleted + title: "Root", + children: [ + { + type: "page", + id: FernNavigation.NodeId("page2"), + 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, + noindex: undefined, + + // NOTE: points to is updated! + pointsTo: "root/page", + }); + }); +}); 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..78930c79ed --- /dev/null +++ b/packages/fdr-sdk/src/navigation/utils/deleteChild.ts @@ -0,0 +1,113 @@ +import { MarkOptional, UnreachableCaseError } from "ts-essentials"; +import { FernNavigation } from "../.."; +import { DeleterAction } from "../../utils/traversers/types"; + +/** + * @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 | undefined, + node: FernNavigation.NavigationNode, +): DeleterAction { + /** + * The idea here is we should only delete leaf nodes (we're treating changelogs here like a leaf node) + * + * In the case that we have sections that have content, deleting it from its parent would delete all its children as well. + * Instead, we'll just remove the overviewPageId, which will make the section a non-visitable node, yet still retain its children. + */ + if ( + !FernNavigation.isLeaf(node) && + FernNavigation.isPage(node) && + FernNavigation.getChildren(node).length > 0 && + node.type !== "changelog" + ) { + // if the node to be deleted is a section, remove the overviewPageId + if (FernNavigation.isSectionOverview(node)) { + (node as MarkOptional).overviewPageId = undefined; + + if (node.children.length === 0) { + return "deleted"; + } + + return "noop"; + } else { + throw new UnreachableCaseError(node); + } + } + + // if the node is not a leaf node, don't delete it from the parent unless it has no children + if (!FernNavigation.isLeaf(node) && FernNavigation.getChildren(node).length > 0) { + return "noop"; + } + + if (parent == null) { + return "deleted"; + } + + switch (parent.type) { + case "apiPackage": + parent.children = parent.children.filter((child) => child.id !== node.id); + break; + case "apiReference": + parent.children = parent.children.filter((child) => child.id !== node.id); + parent.changelog = parent.changelog?.id === node.id ? undefined : parent.changelog; + break; + case "changelog": + parent.children = parent.children.filter((child) => child.id !== node.id); + break; + case "changelogYear": + parent.children = parent.children.filter((child) => child.id !== node.id); + break; + case "changelogMonth": + parent.children = parent.children.filter((child) => child.id !== node.id); + break; + case "endpointPair": + return "should-delete-parent"; + case "productgroup": + parent.children = parent.children.filter((child) => child.id !== node.id); + parent.landingPage = parent.landingPage?.id === node.id ? undefined : parent.landingPage; + break; + case "product": + case "root": + return "should-delete-parent"; + case "unversioned": + if (node.id === parent.landingPage?.id) { + parent.landingPage = undefined; + } + break; + case "section": + parent.children = parent.children.filter((child) => child.id !== node.id); + break; + case "sidebarGroup": + parent.children = parent.children.filter((child) => child.id !== node.id); + break; + case "tab": + return "should-delete-parent"; + case "sidebarRoot": + parent.children = parent.children.filter((child) => child.id !== node.id); + break; + case "tabbed": + parent.children = parent.children.filter((child) => child.id !== node.id); + break; + case "version": + if (node.id === parent.landingPage?.id) { + parent.landingPage = undefined; + } + break; + case "versioned": + parent.children = parent.children.filter((child) => child.id !== node.id); + break; + default: + throw new UnreachableCaseError(parent); + } + + if (FernNavigation.isPage(parent)) { + return "noop"; + } else if (FernNavigation.getChildren(parent).length > 0) { + return "deleted"; + } else { + return "should-delete-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 new file mode 100644 index 0000000000..e27f596759 --- /dev/null +++ b/packages/fdr-sdk/src/navigation/utils/followRedirect.ts @@ -0,0 +1,64 @@ +import { UnreachableCaseError } from "ts-essentials"; +import { FernNavigation } from "../.."; + +export function followRedirect( + nodeToFollow: FernNavigation.NavigationNode | undefined, +): FernNavigation.Slug | undefined { + if (nodeToFollow == null) { + return undefined; + } + + if (FernNavigation.isPage(nodeToFollow)) { + return nodeToFollow.slug; + } + + 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 "changelogMonth": // note: changelog month nodes don't exist yet as pages + case "changelogYear": // note: changelog month nodes don't exist yet as pages + 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: readonly FernNavigation.NavigationNode[]): FernNavigation.Slug | undefined { + for (const node of nodes) { + // skip hidden nodes + if (FernNavigation.hasMetadata(node) && node.hidden) { + continue; + } + const redirect = followRedirect(node); + if (redirect != null) { + return redirect; + } + } + 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 new file mode 100644 index 0000000000..c0d839e8a5 --- /dev/null +++ b/packages/fdr-sdk/src/navigation/utils/hasChildren.ts @@ -0,0 +1,43 @@ +import { UnreachableCaseError } from "ts-essentials"; +import { NavigationNodeParent } from "../versions"; + +export function hasChildren(node: NavigationNodeParent): boolean { + switch (node.type) { + case "apiPackage": + return node.children.length > 0; + case "apiReference": + return node.children.length > 0 || node.changelog != null; + case "changelog": + return node.children.length > 0; + case "changelogMonth": + return node.children.length > 0; + case "changelogYear": + return node.children.length > 0; + case "endpointPair": + return true; + case "productgroup": + return node.children.length > 0 || node.landingPage != null; + case "product": + return true; + case "root": + return true; + case "unversioned": + return true; + case "section": + return node.children.length > 0; + case "sidebarGroup": + return node.children.length > 0; + case "tab": + return true; + case "sidebarRoot": + return node.children.length > 0; + case "tabbed": + return node.children.length > 0; + case "version": + return true; + case "versioned": + return node.children.length > 0; + default: + throw new UnreachableCaseError(node); + } +} diff --git a/packages/fdr-sdk/src/navigation/utils/index.ts b/packages/fdr-sdk/src/navigation/utils/index.ts index 44f3d27e67..b1e2b704f8 100644 --- a/packages/fdr-sdk/src/navigation/utils/index.ts +++ b/packages/fdr-sdk/src/navigation/utils/index.ts @@ -4,5 +4,6 @@ export * from "./createBreadcrumbs"; export * from "./findNode"; export * from "./getApiReferenceId"; export * from "./getNoIndexFromFrontmatter"; +export * from "./pruneNavigationTree"; export * from "./toRootNode"; export * from "./toUnversionedSlug"; diff --git a/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts b/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts new file mode 100644 index 0000000000..2dea9530bf --- /dev/null +++ b/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts @@ -0,0 +1,46 @@ +import structuredClone from "@ungap/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 { 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 a new navigation tree with only the nodes that should be kept + */ +export function pruneNavigationTree( + root: DeepReadonly, + keep: (node: FernNavigation.NavigationNode) => boolean, +): ROOT | undefined { + const clone = structuredClone(root) as ROOT; + return mutablePruneNavigationTree(clone, keep); +} + +function mutablePruneNavigationTree( + root: ROOT, + keep: (node: FernNavigation.NavigationNode) => boolean, +): ROOT | undefined { + const [result] = 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: FernNavigation.NavigationNodeParent) => + // !hasChildren(parent) && !FernNavigation.isPage(parent), + }); + + if (result == null) { + return undefined; + } + + // since the tree has been pruned, we need to update the pointsTo property + mutableUpdatePointsTo(result); + + 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 new file mode 100644 index 0000000000..17b64fb1f5 --- /dev/null +++ b/packages/fdr-sdk/src/navigation/utils/updatePointsTo.ts @@ -0,0 +1,16 @@ +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 mutableUpdatePointsTo(input: FernNavigation.NavigationNode): void { + FernNavigation.traverseDF(input, (node) => { + if (FernNavigation.hasPointsTo(node)) { + const pointsTo = followRedirect(node); + node.pointsTo = node.slug === pointsTo ? undefined : pointsTo; + } + }); +} diff --git a/packages/fdr-sdk/src/navigation/versions/latest/NavigationNodeLeaf.ts b/packages/fdr-sdk/src/navigation/versions/latest/NavigationNodeLeaf.ts index 132c8e7558..d2d7c55439 100644 --- a/packages/fdr-sdk/src/navigation/versions/latest/NavigationNodeLeaf.ts +++ b/packages/fdr-sdk/src/navigation/versions/latest/NavigationNodeLeaf.ts @@ -1,3 +1,4 @@ +import { LinkNode } from "."; import type { NavigationNode } from "./NavigationNode"; import { isApiLeaf, type NavigationNodeApiLeaf } from "./NavigationNodeApiLeaf"; import { isMarkdownLeaf, type NavigationNodeMarkdownLeaf } from "./NavigationNodePageLeaf"; @@ -5,8 +6,8 @@ import { isMarkdownLeaf, type NavigationNodeMarkdownLeaf } from "./NavigationNod /** * A navigation node that represents a leaf in the navigation tree (i.e. a node that does not have children) */ -export type NavigationNodeLeaf = NavigationNodeApiLeaf | NavigationNodeMarkdownLeaf; +export type NavigationNodeLeaf = NavigationNodeApiLeaf | NavigationNodeMarkdownLeaf | LinkNode; export function isLeaf(node: NavigationNode): node is NavigationNodeLeaf { - return isApiLeaf(node) || isMarkdownLeaf(node); + return isApiLeaf(node) || isMarkdownLeaf(node) || node.type === "link"; } diff --git a/packages/fdr-sdk/src/navigation/versions/latest/NavigationNodePage.ts b/packages/fdr-sdk/src/navigation/versions/latest/NavigationNodePage.ts index 159b90ee8a..c16871db34 100644 --- a/packages/fdr-sdk/src/navigation/versions/latest/NavigationNodePage.ts +++ b/packages/fdr-sdk/src/navigation/versions/latest/NavigationNodePage.ts @@ -1,4 +1,4 @@ -import type { ChangelogMonthNode, ChangelogNode, ChangelogYearNode } from "."; +import type { ChangelogNode } from "."; import type { NavigationNode } from "./NavigationNode"; import { isApiLeaf, type NavigationNodeApiLeaf } from "./NavigationNodeApiLeaf"; import { hasMarkdown, type NavigationNodeWithMarkdown } from "./NavigationNodeMarkdown"; @@ -6,14 +6,11 @@ import { hasMarkdown, type NavigationNodeWithMarkdown } from "./NavigationNodeMa /** * A navigation node that represents a visitable page in the documentation */ -export type NavigationNodePage = - | NavigationNodeWithMarkdown - | NavigationNodeApiLeaf - | ChangelogNode - | ChangelogYearNode - | ChangelogMonthNode; +export type NavigationNodePage = NavigationNodeWithMarkdown | NavigationNodeApiLeaf | ChangelogNode; +// | ChangelogYearNode +// | ChangelogMonthNode; -export function isPage(node: NavigationNode): node is NavigationNodePage { +export function isPage(node: N): node is N & NavigationNodePage { return ( isApiLeaf(node) || node.type === "changelog" || diff --git a/packages/fdr-sdk/src/navigation/versions/latest/NavigationNodeParent.ts b/packages/fdr-sdk/src/navigation/versions/latest/NavigationNodeParent.ts new file mode 100644 index 0000000000..8aa1c18b2f --- /dev/null +++ b/packages/fdr-sdk/src/navigation/versions/latest/NavigationNodeParent.ts @@ -0,0 +1,4 @@ +import { NavigationNode } from "./NavigationNode"; +import { NavigationNodeLeaf } from "./NavigationNodeLeaf"; + +export type NavigationNodeParent = Exclude; diff --git a/packages/fdr-sdk/src/navigation/versions/latest/NavigationNodeWithRedirect.ts b/packages/fdr-sdk/src/navigation/versions/latest/NavigationNodeWithRedirect.ts index b8306f7a59..cae864edce 100644 --- a/packages/fdr-sdk/src/navigation/versions/latest/NavigationNodeWithRedirect.ts +++ b/packages/fdr-sdk/src/navigation/versions/latest/NavigationNodeWithRedirect.ts @@ -1,11 +1,49 @@ -import type { WithRedirect } from "."; +import type { Exact, MarkRequired } from "ts-essentials"; +import type { + ApiPackageNode, + ApiReferenceNode, + ProductNode, + RootNode, + SectionNode, + TabNode, + VersionNode, + WithRedirect, +} from "."; import type { NavigationNode } from "./NavigationNode"; +/** + * Navigation nodes that can have a redirect + */ +export type NavigationNodeWithPointsTo = + | RootNode + | ProductNode + | VersionNode + | TabNode + | SectionNode + | ApiReferenceNode + | ApiPackageNode; + +export function hasPointsTo(node: NavigationNode): node is NavigationNodeWithRedirect { + return ( + node.type === "root" || + node.type === "product" || + node.type === "version" || + node.type === "tab" || + node.type === "section" || + node.type === "apiReference" || + node.type === "apiPackage" + ); +} + /** * Navigation nodes that extend WithRedirect */ -export type NavigationNodeWithRedirect = Extract; +export type NavigationNodeWithRedirect = Exact> & + MarkRequired; export function hasRedirect(node: NavigationNode): node is NavigationNodeWithRedirect { - return typeof (node as NavigationNodeWithRedirect).pointsTo === "string"; + if (!hasPointsTo(node)) { + return false; + } + return node.pointsTo != null; } 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/getPageId.ts b/packages/fdr-sdk/src/navigation/versions/latest/getPageId.ts index 859a55579d..713efe60fa 100644 --- a/packages/fdr-sdk/src/navigation/versions/latest/getPageId.ts +++ b/packages/fdr-sdk/src/navigation/versions/latest/getPageId.ts @@ -19,7 +19,7 @@ export function getPageId(node: NavigationNodePage): PageId | undefined { endpoint: RETURN_UNDEFINED, webSocket: RETURN_UNDEFINED, webhook: RETURN_UNDEFINED, - changelogYear: RETURN_UNDEFINED, - changelogMonth: RETURN_UNDEFINED, + // changelogYear: RETURN_UNDEFINED, + // changelogMonth: RETURN_UNDEFINED, }); } diff --git a/packages/fdr-sdk/src/navigation/versions/latest/index.ts b/packages/fdr-sdk/src/navigation/versions/latest/index.ts index 0ec5e6cdf5..609075cabe 100644 --- a/packages/fdr-sdk/src/navigation/versions/latest/index.ts +++ b/packages/fdr-sdk/src/navigation/versions/latest/index.ts @@ -7,10 +7,12 @@ export * from "./NavigationNodeMarkdown"; export * from "./NavigationNodeNeighbor"; export * from "./NavigationNodePage"; export * from "./NavigationNodePageLeaf"; +export * from "./NavigationNodeParent"; export * from "./NavigationNodeSection"; export * from "./NavigationNodeSectionOverview"; export * from "./NavigationNodeWithMetadata"; export * from "./NavigationNodeWithRedirect"; +export * from "./getChildren"; export * from "./getPageId"; export * from "./isApiReferenceNode"; export * from "./isSidebarRootNode"; @@ -19,4 +21,5 @@ export * from "./isUnversionedNode"; export * from "./isVersionNode"; 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 4a5c965e14..0000000000 --- a/packages/fdr-sdk/src/navigation/versions/latest/traverseNavigation.ts +++ /dev/null @@ -1,99 +0,0 @@ -import visitDiscriminatedUnion from "@fern-ui/core-utils/visitDiscriminatedUnion"; -import { noop } from "ts-essentials"; -import { NavigationNode } from "./NavigationNode"; - -const SKIP = "skip" as const; -// const CONTINUE = true as const; -const STOP = false as const; - -export function traverseNavigation( - node: NavigationNode, - visit: (node: NavigationNode, index: number | undefined, parents: NavigationNode[]) => boolean | typeof SKIP | void, -): void { - function internalChildrenTraverser(nodes: NavigationNode[], parents: NavigationNode[]): 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: NavigationNode[], - ): 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: (produtgroup) => internalChildrenTraverser(produtgroup.children, [...parents, produtgroup]), - 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, []); -} diff --git a/packages/fdr-sdk/src/navigation/versions/v1/NavigationNodeLeaf.ts b/packages/fdr-sdk/src/navigation/versions/v1/NavigationNodeLeaf.ts index 132c8e7558..d2d7c55439 100644 --- a/packages/fdr-sdk/src/navigation/versions/v1/NavigationNodeLeaf.ts +++ b/packages/fdr-sdk/src/navigation/versions/v1/NavigationNodeLeaf.ts @@ -1,3 +1,4 @@ +import { LinkNode } from "."; import type { NavigationNode } from "./NavigationNode"; import { isApiLeaf, type NavigationNodeApiLeaf } from "./NavigationNodeApiLeaf"; import { isMarkdownLeaf, type NavigationNodeMarkdownLeaf } from "./NavigationNodePageLeaf"; @@ -5,8 +6,8 @@ import { isMarkdownLeaf, type NavigationNodeMarkdownLeaf } from "./NavigationNod /** * A navigation node that represents a leaf in the navigation tree (i.e. a node that does not have children) */ -export type NavigationNodeLeaf = NavigationNodeApiLeaf | NavigationNodeMarkdownLeaf; +export type NavigationNodeLeaf = NavigationNodeApiLeaf | NavigationNodeMarkdownLeaf | LinkNode; export function isLeaf(node: NavigationNode): node is NavigationNodeLeaf { - return isApiLeaf(node) || isMarkdownLeaf(node); + return isApiLeaf(node) || isMarkdownLeaf(node) || node.type === "link"; } diff --git a/packages/fdr-sdk/src/navigation/versions/v1/converters/NavigationConfigConverter.ts b/packages/fdr-sdk/src/navigation/versions/v1/converters/NavigationConfigConverter.ts index fed94ae4f0..bad6f13682 100644 --- a/packages/fdr-sdk/src/navigation/versions/v1/converters/NavigationConfigConverter.ts +++ b/packages/fdr-sdk/src/navigation/versions/v1/converters/NavigationConfigConverter.ts @@ -79,7 +79,7 @@ export class NavigationConfigConverter { }; // tag all children of hidden nodes as hidden - FernNavigation.V1.traverseNavigation(toRet, (node, _index, parents) => { + FernNavigation.V1.traverseDF(toRet, (node, parents) => { if ( FernNavigation.V1.hasMetadata(node) && parents.some((p) => FernNavigation.V1.hasMetadata(p) && p.hidden === true) diff --git a/packages/fdr-sdk/src/navigation/versions/v1/getChildren.ts b/packages/fdr-sdk/src/navigation/versions/v1/getChildren.ts new file mode 100644 index 0000000000..23731be0f0 --- /dev/null +++ b/packages/fdr-sdk/src/navigation/versions/v1/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/v1/index.ts b/packages/fdr-sdk/src/navigation/versions/v1/index.ts index 964859d2c1..e357e5af9e 100644 --- a/packages/fdr-sdk/src/navigation/versions/v1/index.ts +++ b/packages/fdr-sdk/src/navigation/versions/v1/index.ts @@ -1,10 +1,5 @@ export * from "../../../client/generated/api/resources/commons"; export * from "../../../client/generated/api/resources/navigation/resources/v1/types"; -export * from "./convertAvailability"; -export * from "./converters/ApiReferenceNavigationConverter"; -export * from "./converters/toRootNode"; -export * from "./followRedirect"; -export * from "./getPageId"; export * from "./NavigationNode"; export * from "./NavigationNodeApiLeaf"; export * from "./NavigationNodeLeaf"; @@ -16,6 +11,11 @@ export * from "./NavigationNodeSection"; export * from "./NavigationNodeSectionOverview"; export * from "./NavigationNodeWithMetadata"; export * from "./NavigationNodeWithRedirect"; +export * from "./convertAvailability"; +export * from "./converters/ApiReferenceNavigationConverter"; +export * from "./converters/toRootNode"; +export * from "./followRedirect"; +export * from "./getPageId"; export * from "./slugjoin"; export * from "./toDefaultSlug"; -export * from "./traverseNavigation"; +export * from "./traverseDF"; diff --git a/packages/fdr-sdk/src/navigation/versions/v1/traverseDF.ts b/packages/fdr-sdk/src/navigation/versions/v1/traverseDF.ts new file mode 100644 index 0000000000..5e8467e302 --- /dev/null +++ b/packages/fdr-sdk/src/navigation/versions/v1/traverseDF.ts @@ -0,0 +1,8 @@ +import { dfs } from "../../../utils/traversers/dfs"; +import { TraverserVisit } from "../../../utils/traversers/types"; +import { NavigationNode } from "./NavigationNode"; +import { getChildren } from "./getChildren"; + +export function traverseDF(node: NavigationNode, visit: TraverserVisit): void { + return dfs(node, visit, getChildren); +} diff --git a/packages/fdr-sdk/src/navigation/versions/v1/traverseNavigation.ts b/packages/fdr-sdk/src/navigation/versions/v1/traverseNavigation.ts deleted file mode 100644 index 4a5c965e14..0000000000 --- a/packages/fdr-sdk/src/navigation/versions/v1/traverseNavigation.ts +++ /dev/null @@ -1,99 +0,0 @@ -import visitDiscriminatedUnion from "@fern-ui/core-utils/visitDiscriminatedUnion"; -import { noop } from "ts-essentials"; -import { NavigationNode } from "./NavigationNode"; - -const SKIP = "skip" as const; -// const CONTINUE = true as const; -const STOP = false as const; - -export function traverseNavigation( - node: NavigationNode, - visit: (node: NavigationNode, index: number | undefined, parents: NavigationNode[]) => boolean | typeof SKIP | void, -): void { - function internalChildrenTraverser(nodes: NavigationNode[], parents: NavigationNode[]): 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: NavigationNode[], - ): 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: (produtgroup) => internalChildrenTraverser(produtgroup.children, [...parents, produtgroup]), - 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, []); -} 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..e52eb27a87 --- /dev/null +++ b/packages/fdr-sdk/src/utils/traversers/__test__/prunetree.test.ts @@ -0,0 +1,87 @@ +import { prunetree } from "../prunetree"; +import { DeleterAction } from "../types"; +import { FIXTURE, Record } from "./fixture"; + +const DELETER = (parent: Record | undefined, child: Record): DeleterAction => { + if (parent == null) { + return "deleted"; + } + parent.children = parent.children.filter((c) => c.id !== child.id); + + if (parent.children.length === 0) { + return "should-delete-parent"; + } + + return "deleted"; +}; + +const testPruner = (predicate: (node: Record) => boolean): Record | undefined => { + const [pruned] = prunetree(structuredClone(FIXTURE), { + predicate, + getChildren: (node) => node.children, + deleter: DELETER, + getPointer: (node) => node.id, + }); + return pruned; +}; + +describe("prunetree", () => { + it("should return the same tree if the predicate returns true for all nodes", () => { + const pruned = testPruner(() => { + return true; + }); + expect(pruned).toStrictEqual(FIXTURE); + }); + + it("should return undefined if the predicate returns false for all nodes", () => { + const pruned = testPruner(() => { + return false; + }); + expect(pruned).toBeUndefined(); + }); + + it("should prune the tree if the predicate returns false for some nodes", () => { + const pruned = testPruner((node) => { + return node.id !== 1; + }); + 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 = testPruner((node) => { + return node.id !== 7 && node.id !== 3; + }); + 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..d34aad46dc --- /dev/null +++ b/packages/fdr-sdk/src/utils/traversers/prunetree.ts @@ -0,0 +1,81 @@ +import { bfs } from "./bfs"; +import { DeleterAction } from "./types"; + +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 | undefined, child: NODE) => DeleterAction; + + /** + * 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, getPointer = (node) => node as unknown as POINTER } = opts; + + const deleted = new Set(); + + const nodes: [NODE, readonly PARENT[]][] = []; + + bfs( + root, + (node, parents) => { + nodes.unshift([node, parents]); + }, + getChildren, + ); + + nodes.forEach(([node, parents]) => { + const order = [...parents, node]; + const deletedIdx = order.findIndex((n) => deleted.has(getPointer(n))); + if (deletedIdx !== -1) { + order.slice(deletedIdx).forEach((n) => deleted.add(getPointer(n))); + return; + } + + // continue traversal if the node is not to be deleted + if (predicate(node)) { + return; + } + const ancestors = [...parents]; + const parent = ancestors.pop(); + + let action = deleter(parent, node); + let toDelete = node; + + while (action === "should-delete-parent" && parent != null) { + deleted.add(getPointer(toDelete)); + toDelete = parent; + action = deleter(ancestors.pop(), parent); + } + + if (action === "deleted") { + deleted.add(getPointer(toDelete)); + } + }); + + if (deleted.has(getPointer(root))) { + return [undefined, deleted]; + } + + return [root, 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..b091a9ac81 --- /dev/null +++ b/packages/fdr-sdk/src/utils/traversers/types.ts @@ -0,0 +1,10 @@ +export const SKIP = "skip" as const; +export const STOP = false; +export const CONTINUE = true; + +export type Action = typeof CONTINUE | typeof SKIP | typeof STOP | void; + +export type TraverserVisit = (node: N, parents: readonly P[]) => Action; +export type TraverserGetChildren = (parent: P) => readonly N[]; + +export type DeleterAction = "deleted" | "should-delete-parent" | "noop"; diff --git a/packages/fdr-sdk/tsconfig.json b/packages/fdr-sdk/tsconfig.json index a987228090..a4997cbc95 100644 --- a/packages/fdr-sdk/tsconfig.json +++ b/packages/fdr-sdk/tsconfig.json @@ -4,7 +4,8 @@ "compilerOptions": { "outDir": "./dist", "rootDir": "./src", - "lib": ["DOM", "ESNext"] + "lib": ["DOM", "ESNext"], + "esModuleInterop": true }, "include": ["./src/**/*"], "exclude": ["node_modules"], diff --git a/packages/ui/app/package.json b/packages/ui/app/package.json index 360cdd600e..69c75b4735 100644 --- a/packages/ui/app/package.json +++ b/packages/ui/app/package.json @@ -60,7 +60,6 @@ "@sentry/nextjs": "^8.30.0", "@shikijs/transformers": "^1.2.2", "@types/nprogress": "^0.2.3", - "@vercel/edge-config": "^1.1.0", "algoliasearch": "^4.24.0", "bezier-easing": "^2.1.0", "clsx": "^2.1.0", diff --git a/packages/ui/app/src/atoms/auth.ts b/packages/ui/app/src/atoms/auth.ts index 39936451b5..84ee7a4f6a 100644 --- a/packages/ui/app/src/atoms/auth.ts +++ b/packages/ui/app/src/atoms/auth.ts @@ -1,7 +1,7 @@ import { useAtomValue } from "jotai"; import { selectAtom } from "jotai/utils"; import { isEqual } from "lodash-es"; -import { FernUser } from "../auth"; +import type { FernUser } from "../auth"; import { DOCS_ATOM } from "./docs"; export const FERN_USER_ATOM = selectAtom(DOCS_ATOM, (docs) => docs.user, isEqual); 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/auth/checkViewerAllowed.ts b/packages/ui/app/src/auth/checkViewerAllowed.ts deleted file mode 100644 index 45b39ef5e0..0000000000 --- a/packages/ui/app/src/auth/checkViewerAllowed.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { NextApiRequest } from "next"; -import type { NextRequest } from "next/server"; -import { verifyFernJWT } from "./FernJWT"; -import { getAuthEdgeConfig } from "./getAuthEdgeConfig"; - -export async function checkViewerAllowedEdge(domain: string, req: NextRequest): Promise { - const auth = await getAuthEdgeConfig(domain); - const fern_token = req.cookies.get("fern_token")?.value; - - if (auth?.type === "basic_token_verification") { - if (fern_token == null) { - return 401; - } else { - const verified = verifyFernJWT(fern_token, auth.secret, auth.issuer); - if (!verified) { - return 403; - } - } - } - return 200; -} - -export async function checkViewerAllowedNode(domain: string, req: NextApiRequest): Promise { - const auth = await getAuthEdgeConfig(domain); - const fern_token = req.cookies.fern_token; - - if (auth?.type === "basic_token_verification") { - if (fern_token == null) { - return 401; - } else { - const verified = verifyFernJWT(fern_token, auth.secret, auth.issuer); - if (!verified) { - return 403; - } - } - } - return 200; -} diff --git a/packages/ui/app/src/auth/index.ts b/packages/ui/app/src/auth/index.ts index c9fabb5d0f..3a4770d25e 100644 --- a/packages/ui/app/src/auth/index.ts +++ b/packages/ui/app/src/auth/index.ts @@ -1,12 +1,2 @@ -export * from "./FernJWT"; -export { OAuth2Client } from "./OAuth2Client"; -export { checkViewerAllowedEdge, checkViewerAllowedNode } from "./checkViewerAllowed"; -export { - getAPIKeyInjectionConfig, - getAPIKeyInjectionConfigNode, - type APIKeyInjectionConfig, -} from "./getApiKeyInjectionConfig"; -export { getAuthEdgeConfig } from "./getAuthEdgeConfig"; +export * from "./injection"; export * from "./types"; -export { decodeAccessToken } from "./verifyAccessToken"; -export { withSecureCookie } from "./withSecure"; diff --git a/packages/ui/app/src/auth/injection.ts b/packages/ui/app/src/auth/injection.ts new file mode 100644 index 0000000000..7335f0d0df --- /dev/null +++ b/packages/ui/app/src/auth/injection.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +export const APIKeyInjectionConfigDisabledSchema = z.object({ + enabled: z.literal(false), +}); + +export const APIKeyInjectionConfigUnauthorizedSchema = z.object({ + enabled: z.literal(true), + authenticated: z.literal(false), + url: z.string(), + partner: z.string().optional(), +}); + +export const APIKeyInjectionConfigAuthorizedSchema = z.object({ + enabled: z.literal(true), + authenticated: z.literal(true), + access_token: z.string(), + refresh_token: z.string().optional(), + exp: z.number().optional(), + partner: z.string().optional(), +}); + +export const APIKeyInjectionConfigSchema = z.union([ + APIKeyInjectionConfigDisabledSchema, + APIKeyInjectionConfigUnauthorizedSchema, + APIKeyInjectionConfigAuthorizedSchema, +]); + +export type APIKeyInjectionConfigDisabled = z.infer; +export type APIKeyInjectionConfigUnauthorized = z.infer; +export type APIKeyInjectionConfigAuthorized = z.infer; +export type APIKeyInjectionConfig = z.infer; diff --git a/packages/ui/app/src/auth/types.ts b/packages/ui/app/src/auth/types.ts index a4af571830..40c80a1b6c 100644 --- a/packages/ui/app/src/auth/types.ts +++ b/packages/ui/app/src/auth/types.ts @@ -34,6 +34,17 @@ export const AuthEdgeConfigBasicTokenVerificationSchema = z.object({ secret: z.string(), issuer: z.string(), redirect: z.string(), + + allowlist: z + .array(z.string(), { + description: "List of pages (regexp allowed) that are public and do not require authentication", + }) + .optional(), + denylist: z + .array(z.string(), { + description: "List of pages (regexp allowed) that are private and require authentication", + }) + .optional(), }); export const AuthEdgeConfigSchema = z.union([ diff --git a/packages/ui/app/src/hooks/usePlaygroundSettings.ts b/packages/ui/app/src/hooks/usePlaygroundSettings.ts index a6a3a73ceb..3a0674b911 100644 --- a/packages/ui/app/src/hooks/usePlaygroundSettings.ts +++ b/packages/ui/app/src/hooks/usePlaygroundSettings.ts @@ -15,7 +15,7 @@ export function usePlaygroundSettings(currentNodeId?: NodeId): PlaygroundSetting if (maybeCurrentHasPlayground) { return maybeCurrentHasPlayground; } else { - for (const node of navigationNodes.getParents(nodeIdToUse).reverse()) { + for (const node of [...navigationNodes.getParents(nodeIdToUse)].reverse()) { const maybeNodeHasPlayground = nodeHasPlayground(node); if (maybeNodeHasPlayground) { return maybeNodeHasPlayground; diff --git a/packages/ui/app/src/playground/utils/breadcrumb.ts b/packages/ui/app/src/playground/utils/breadcrumb.ts index b6328823f0..9b6cb55304 100644 --- a/packages/ui/app/src/playground/utils/breadcrumb.ts +++ b/packages/ui/app/src/playground/utils/breadcrumb.ts @@ -21,7 +21,7 @@ interface BreadcrumbSlicerOpts { } /** - * assumes that elements are ordered via in-order traversal of a navigation tree. + * assumes that elements are ordered via pre-order traversal of a navigation tree. * * if the breadcrumbs of the list looks like this: * [a, b] diff --git a/packages/ui/app/src/playground/utils/flatten-apis.ts b/packages/ui/app/src/playground/utils/flatten-apis.ts index 4c8ac7fd96..34e0f7188d 100644 --- a/packages/ui/app/src/playground/utils/flatten-apis.ts +++ b/packages/ui/app/src/playground/utils/flatten-apis.ts @@ -20,7 +20,7 @@ export function flattenApiSection(root: FernNavigation.SidebarRootNode | undefin return []; } const result: ApiGroup[] = []; - FernNavigation.traverseNavigation(root, (node, _, parents) => { + FernNavigation.traverseDF(root, (node, parents) => { if (node.type === "changelog") { return "skip"; } diff --git a/packages/ui/app/src/seo/getBreadcrumbList.ts b/packages/ui/app/src/seo/getBreadcrumbList.ts index a7677b0dc5..566dae61a5 100644 --- a/packages/ui/app/src/seo/getBreadcrumbList.ts +++ b/packages/ui/app/src/seo/getBreadcrumbList.ts @@ -12,7 +12,7 @@ function toUrl(domain: string, slug: FernNavigation.Slug): string { export function getBreadcrumbList( domain: string, pages: Record, - parents: FernNavigation.NavigationNode[], + parents: readonly FernNavigation.NavigationNode[], node: FernNavigation.NavigationNodePage, ): FernDocs.JsonLdBreadcrumbList { let title = node.title; diff --git a/packages/ui/app/src/util/resolveDocsContent.ts b/packages/ui/app/src/util/resolveDocsContent.ts index 1150622e39..0d58ac4699 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) { @@ -270,7 +270,7 @@ async function resolveMarkdownPage( filename: pageId, frontmatterDefaults: { title: node.title, - breadcrumb: found.breadcrumb, + breadcrumb: [...found.breadcrumb], "edit-this-page-url": pageContent.editThisPageUrl, "force-toc": featureFlags.isTocDefaultEnabled, }, diff --git a/packages/ui/docs-bundle/package.json b/packages/ui/docs-bundle/package.json index c471e55c67..037391b3d1 100644 --- a/packages/ui/docs-bundle/package.json +++ b/packages/ui/docs-bundle/package.json @@ -50,6 +50,7 @@ "feed": "^4.2.2", "form-data": "4.0.0", "httpsnippet-lite": "^3.0.5", + "jose": "^5.2.3", "jsonpath": "^1.1.1", "next": "npm:@fern-api/next@14.2.9-fork.0", "node-fetch": "2.7.0", diff --git a/packages/ui/docs-bundle/src/middleware.ts b/packages/ui/docs-bundle/src/middleware.ts index 975c7ce8df..5059521149 100644 --- a/packages/ui/docs-bundle/src/middleware.ts +++ b/packages/ui/docs-bundle/src/middleware.ts @@ -2,10 +2,14 @@ import { extractBuildId, extractNextDataPathname } from "@/server/extractNextDat import { getPageRoute, getPageRouteMatch, getPageRoutePath } from "@/server/pageRoutes"; import { rewritePosthog } from "@/server/rewritePosthog"; import { getXFernHostEdge } from "@/server/xfernhost/edge"; -import { FernUser, getAuthEdgeConfig, verifyFernJWTConfig } from "@fern-ui/ui/auth"; +import type { FernUser } from "@fern-ui/ui/auth"; import { removeTrailingSlash } from "next/dist/shared/lib/router/utils/remove-trailing-slash"; import { NextRequest, NextResponse, type NextMiddleware } from "next/server"; import urlJoin from "url-join"; +import { verifyFernJWTConfig } from "./server/auth/FernJWT"; +import { getAuthEdgeConfig } from "./server/auth/getAuthEdgeConfig"; +import { COOKIE_FERN_TOKEN, HEADER_X_FERN_HOST } from "./server/constants"; +import { withBasicTokenPublic } from "./server/withBasicTokenPublic"; const API_FERN_DOCS_PATTERN = /^(?!\/api\/fern-docs\/).*(\/api\/fern-docs\/)/; const CHANGELOG_PATTERN = /\.(rss|atom)$/; @@ -28,8 +32,8 @@ export const middleware: NextMiddleware = async (request) => { /** * Add x-fern-host header to the request */ - if (!headers.has("x-fern-host")) { - headers.set("x-fern-host", xFernHost); + if (!headers.has(HEADER_X_FERN_HOST)) { + headers.set(HEADER_X_FERN_HOST, xFernHost); } /** @@ -77,7 +81,7 @@ export const middleware: NextMiddleware = async (request) => { const pathname = extractNextDataPathname(request.nextUrl.pathname); - const fernToken = request.cookies.get("fern_token"); + const fernToken = request.cookies.get(COOKIE_FERN_TOKEN); const authConfig = await getAuthEdgeConfig(xFernHost); let fernUser: FernUser | undefined; @@ -98,9 +102,11 @@ export const middleware: NextMiddleware = async (request) => { * redirect to the custom auth provider */ if (!isLoggedIn && authConfig?.type === "basic_token_verification") { - const destination = new URL(authConfig.redirect); - destination.searchParams.set("state", urlJoin(`https://${xFernHost}`, pathname)); - return NextResponse.redirect(destination, { status: 302 }); + if (!withBasicTokenPublic(authConfig, pathname)) { + const destination = new URL(authConfig.redirect); + destination.searchParams.set("state", urlJoin(`https://${xFernHost}`, pathname)); + return NextResponse.redirect(destination); + } } /** diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/api-definition/[api]/endpoint/[endpoint].ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/api-definition/[api]/endpoint/[endpoint].ts index 1339e9f1f3..b5906aba7d 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/api-definition/[api]/endpoint/[endpoint].ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/api-definition/[api]/endpoint/[endpoint].ts @@ -1,7 +1,6 @@ import { ApiDefinitionLoader } from "@/server/ApiDefinitionLoader"; import { getXFernHostNode } from "@/server/xfernhost/node"; import * as ApiDefinition from "@fern-api/fdr-sdk/api-definition"; -import { checkViewerAllowedNode } from "@fern-ui/ui/auth"; import { NextApiHandler, NextApiResponse } from "next"; import { getFeatureFlags } from "../../../feature-flags"; @@ -13,13 +12,9 @@ const resolveApiHandler: NextApiHandler = async (req, res: NextApiResponse= 400) { - res.status(status).end(); - return; - } - const flags = await getFeatureFlags(xFernHost); + + // TODO: authenticate the request in FDR const apiDefinition = await ApiDefinitionLoader.create(xFernHost, ApiDefinition.ApiDefinitionId(api)) .withFlags(flags) .withPrune({ type: "endpoint", endpointId: ApiDefinition.EndpointId(endpoint) }) diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/api-definition/[api]/webhook/[webhook].ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/api-definition/[api]/webhook/[webhook].ts index 51814338ec..a66f4cd504 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/api-definition/[api]/webhook/[webhook].ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/api-definition/[api]/webhook/[webhook].ts @@ -1,7 +1,6 @@ import { ApiDefinitionLoader } from "@/server/ApiDefinitionLoader"; import { getXFernHostNode } from "@/server/xfernhost/node"; import * as ApiDefinition from "@fern-api/fdr-sdk/api-definition"; -import { checkViewerAllowedNode } from "@fern-ui/ui/auth"; import { NextApiHandler, NextApiResponse } from "next"; import { getFeatureFlags } from "../../../feature-flags"; @@ -13,13 +12,9 @@ const resolveApiHandler: NextApiHandler = async (req, res: NextApiResponse= 400) { - res.status(status).end(); - return; - } - const flags = await getFeatureFlags(xFernHost); + + // TODO: authenticate the request in FDR const apiDefinition = await ApiDefinitionLoader.create(xFernHost, ApiDefinition.ApiDefinitionId(api)) .withFlags(flags) .withPrune({ type: "webhook", webhookId: ApiDefinition.WebhookId(webhook) }) diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/api-definition/[api]/websocket/[websocket].ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/api-definition/[api]/websocket/[websocket].ts index 8142d9615b..7f8acbaa55 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/api-definition/[api]/websocket/[websocket].ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/api-definition/[api]/websocket/[websocket].ts @@ -1,7 +1,6 @@ import { ApiDefinitionLoader } from "@/server/ApiDefinitionLoader"; import { getXFernHostNode } from "@/server/xfernhost/node"; import * as ApiDefinition from "@fern-api/fdr-sdk/api-definition"; -import { checkViewerAllowedNode } from "@fern-ui/ui/auth"; import { NextApiHandler, NextApiResponse } from "next"; import { getFeatureFlags } from "../../../feature-flags"; @@ -13,13 +12,9 @@ const resolveApiHandler: NextApiHandler = async (req, res: NextApiResponse= 400) { - res.status(status).end(); - return; - } - const flags = await getFeatureFlags(xFernHost); + + // TODO: authenticate the request in FDR const apiDefinition = await ApiDefinitionLoader.create(xFernHost, ApiDefinition.ApiDefinitionId(api)) .withFlags(flags) .withPrune({ type: "webSocket", webSocketId: ApiDefinition.WebSocketId(websocket) }) diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/api-key-injection.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/api-key-injection.ts index ef4dbd3212..68dc42e37a 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/api-key-injection.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/api-key-injection.ts @@ -1,12 +1,10 @@ +import { OAuth2Client } from "@/server/auth/OAuth2Client"; +import { getAPIKeyInjectionConfig } from "@/server/auth/getApiKeyInjectionConfig"; +import { getAuthEdgeConfig } from "@/server/auth/getAuthEdgeConfig"; +import { withSecureCookie } from "@/server/auth/withSecure"; +import { COOKIE_FERN_TOKEN } from "@/server/constants"; import { getXFernHostEdge } from "@/server/xfernhost/edge"; -import { - APIKeyInjectionConfig, - OAuth2Client, - OryAccessTokenSchema, - getAPIKeyInjectionConfig, - getAuthEdgeConfig, - withSecureCookie, -} from "@fern-ui/ui/auth"; +import { APIKeyInjectionConfig, OryAccessTokenSchema } from "@fern-ui/ui/auth"; import { NextRequest, NextResponse } from "next/server"; import { WebflowClient } from "webflow-api"; import type { OauthScope } from "webflow-api/api/types/OAuthScope"; @@ -40,15 +38,15 @@ export default async function handler(req: NextRequest): Promise { "/api/fern-docs/oauth/ory/callback", ); // Permanent GET redirect to the Ory callback endpoint - return NextResponse.redirect(nextUrl, { status: 307 }); + return NextResponse.redirect(nextUrl); } try { @@ -63,7 +67,7 @@ export default async function GET(req: NextRequest): Promise { const token = await signFernJWT(fernUser, user); const res = NextResponse.redirect(redirectLocation); - res.cookies.set("fern_token", token, withSecureCookie()); + res.cookies.set(COOKIE_FERN_TOKEN, token, withSecureCookie()); return res; } catch (error) { // eslint-disable-next-line no-console diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/jwt/callback.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/jwt/callback.ts new file mode 100644 index 0000000000..108841a2cd --- /dev/null +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/jwt/callback.ts @@ -0,0 +1,44 @@ +import { verifyFernJWTConfig } from "@/server/auth/FernJWT"; +import { getAuthEdgeConfig } from "@/server/auth/getAuthEdgeConfig"; +import { withSecureCookie } from "@/server/auth/withSecure"; +import { COOKIE_FERN_TOKEN } from "@/server/constants"; +import { getXFernHostEdge } from "@/server/xfernhost/edge"; +import { NextRequest, NextResponse } from "next/server"; + +export const runtime = "edge"; + +function redirectWithLoginError(location: string, errorMessage: string): NextResponse { + const url = new URL(location); + url.searchParams.set("loginError", errorMessage); + return NextResponse.redirect(url.toString()); +} + +export default async function handler(req: NextRequest): Promise { + if (req.method !== "GET") { + return new NextResponse(null, { status: 405 }); + } + + const domain = getXFernHostEdge(req); + const edgeConfig = await getAuthEdgeConfig(domain); + + // since we expect the callback to be redirected to, the token will be in the query params + const token = req.nextUrl.searchParams.get(COOKIE_FERN_TOKEN); + const state = req.nextUrl.searchParams.get("state"); + const redirectLocation = state ?? `https://${domain}/`; + + if (edgeConfig?.type !== "basic_token_verification" || token == null) { + return redirectWithLoginError(redirectLocation, "Couldn't login, please try again"); + } + + try { + await verifyFernJWTConfig(token, edgeConfig); + + const res = NextResponse.redirect(redirectLocation); + res.cookies.set(COOKIE_FERN_TOKEN, token, withSecureCookie()); + return res; + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + return redirectWithLoginError(redirectLocation, "Couldn't login, please try again"); + } +} diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/logout.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/logout.ts index 76e181e775..5bfefbee77 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/logout.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/logout.ts @@ -1,3 +1,4 @@ +import { COOKIE_ACCESS_TOKEN, COOKIE_FERN_TOKEN, COOKIE_REFRESH_TOKEN } from "@/server/constants"; import { getXFernHostEdge } from "@/server/xfernhost/edge"; import { NextRequest, NextResponse } from "next/server"; @@ -10,8 +11,8 @@ export default async function GET(req: NextRequest): Promise { const redirectLocation = state ?? `https://${domain}/`; const res = NextResponse.redirect(redirectLocation); - res.cookies.delete("fern_token"); - res.cookies.delete("access_token"); - res.cookies.delete("refresh_token"); + res.cookies.delete(COOKIE_FERN_TOKEN); + res.cookies.delete(COOKIE_ACCESS_TOKEN); + res.cookies.delete(COOKIE_REFRESH_TOKEN); return res; } diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/changelog.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/changelog.ts index 5f1ea846d0..d5e87cd103 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/changelog.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/changelog.ts @@ -1,12 +1,11 @@ -import { buildUrlFromApiNode } from "@/server/buildUrlFromApi"; -import { loadWithUrl } from "@/server/loadWithUrl"; +import { DocsLoader } from "@/server/DocsLoader"; +import { COOKIE_FERN_TOKEN } from "@/server/constants"; import { getXFernHostNode } from "@/server/xfernhost/node"; import type { DocsV1Read } from "@fern-api/fdr-sdk/client/types"; import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; import { NodeCollector } from "@fern-api/fdr-sdk/navigation"; import { assertNever } from "@fern-ui/core-utils"; import { getFrontmatter } from "@fern-ui/ui"; -import { checkViewerAllowedNode } from "@fern-ui/ui/auth"; import * as Sentry from "@sentry/nextjs"; import { Feed, Item } from "feed"; import { NextApiRequest, NextApiResponse } from "next"; @@ -27,22 +26,15 @@ export default async function responseApiHandler(req: NextApiRequest, res: NextA const xFernHost = getXFernHostNode(req); - const status = await checkViewerAllowedNode(xFernHost, req); - if (status >= 400) { - return res.status(status).end(); - } - - const headers = new Headers(); - headers.set("x-fern-host", xFernHost); + const fernToken = req.cookies[COOKIE_FERN_TOKEN]; + const loader = DocsLoader.for(xFernHost, fernToken); - const url = buildUrlFromApiNode(xFernHost, req); - const docs = await loadWithUrl(url); + const root = await loader.root(); - if (!docs.ok) { + if (!root) { return res.status(404).end(); } - const root = FernNavigation.utils.toRootNode(docs.body); const collector = NodeCollector.collect(root); const slug = FernNavigation.slugjoin(decodeURIComponent(path)); @@ -62,11 +54,14 @@ export default async function responseApiHandler(req: NextApiRequest, res: NextA generator: "buildwithfern.com", }); + const pages = await loader.pages(); + const files = await loader.files(); + node.children.forEach((year) => { year.children.forEach((month) => { month.children.forEach((entry) => { try { - feed.addItem(toFeedItem(entry, xFernHost, docs.body.definition.pages, docs.body.definition.files)); + feed.addItem(toFeedItem(entry, xFernHost, pages, files)); } catch (e) { // eslint-disable-next-line no-console console.error(e); @@ -76,6 +71,8 @@ export default async function responseApiHandler(req: NextApiRequest, res: NextA }); }); + const headers = new Headers(); + if (format === "json") { headers.set("Content-Type", "application/json"); return res.json(feed.json1()); @@ -92,7 +89,7 @@ function toFeedItem( entry: FernNavigation.ChangelogEntryNode, xFernHost: string, pages: Record, - files: Record, + files: Record, ): Item { const item: Item = { title: entry.title, @@ -133,7 +130,7 @@ function toFeedItem( function toUrl( idOrUrl: DocsV1Read.FileIdOrUrl | undefined, - files: Record, + files: Record, ): string | undefined { if (idOrUrl == null) { return undefined; @@ -141,7 +138,7 @@ function toUrl( if (idOrUrl.type === "url") { return idOrUrl.value; } else if (idOrUrl.type === "fileId") { - return files[idOrUrl.value]; + return files[idOrUrl.value]?.url; } else { assertNever(idOrUrl); } diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/oauth/ory/callback.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/oauth/ory/callback.ts index 50753196d1..671982e920 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/oauth/ory/callback.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/oauth/ory/callback.ts @@ -1,12 +1,10 @@ +import { signFernJWT } from "@/server/auth/FernJWT"; +import { OAuth2Client } from "@/server/auth/OAuth2Client"; +import { getAuthEdgeConfig } from "@/server/auth/getAuthEdgeConfig"; +import { withSecureCookie } from "@/server/auth/withSecure"; +import { COOKIE_ACCESS_TOKEN, COOKIE_FERN_TOKEN, COOKIE_REFRESH_TOKEN } from "@/server/constants"; import { getXFernHostEdge } from "@/server/xfernhost/edge"; -import { - FernUser, - OAuth2Client, - OryAccessTokenSchema, - getAuthEdgeConfig, - signFernJWT, - withSecureCookie, -} from "@fern-ui/ui/auth"; +import { FernUser, OryAccessTokenSchema } from "@fern-ui/ui/auth"; import { NextRequest, NextResponse } from "next/server"; export const runtime = "edge"; @@ -56,12 +54,12 @@ export default async function GET(req: NextRequest): Promise { }; const expires = token.exp == null ? undefined : new Date(token.exp * 1000); const res = NextResponse.redirect(redirectLocation); - res.cookies.set("fern_token", await signFernJWT(fernUser), withSecureCookie({ expires })); - res.cookies.set("access_token", access_token, withSecureCookie({ expires })); + res.cookies.set(COOKIE_FERN_TOKEN, await signFernJWT(fernUser), withSecureCookie({ expires })); + res.cookies.set(COOKIE_ACCESS_TOKEN, access_token, withSecureCookie({ expires })); if (refresh_token != null) { - res.cookies.set("refresh_token", refresh_token, withSecureCookie({ expires })); + res.cookies.set(COOKIE_REFRESH_TOKEN, refresh_token, withSecureCookie({ expires })); } else { - res.cookies.delete("refresh_token"); + res.cookies.delete(COOKIE_REFRESH_TOKEN); } return res; } catch (error) { diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/oauth/webflow/callback.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/oauth/webflow/callback.ts index bd99c5736d..1591ab64b3 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/oauth/webflow/callback.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/oauth/webflow/callback.ts @@ -1,5 +1,6 @@ +import { getAuthEdgeConfig } from "@/server/auth/getAuthEdgeConfig"; +import { withSecureCookie } from "@/server/auth/withSecure"; import { getXFernHostEdge } from "@/server/xfernhost/edge"; -import { getAuthEdgeConfig, withSecureCookie } from "@fern-ui/ui/auth"; import { NextRequest, NextResponse } from "next/server"; import { WebflowClient } from "webflow-api"; diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/preview.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/preview.ts index 2528539a77..488ba64d6f 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/preview.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/preview.ts @@ -1,3 +1,4 @@ +import { COOKIE_FERN_DOCS_PREVIEW } from "@/server/constants"; import { notFoundResponse, redirectResponse } from "@/server/serverResponse"; import { NextRequest, NextResponse } from "next/server"; @@ -12,7 +13,7 @@ export default async function GET(req: NextRequest): Promise { const clear = req.nextUrl.searchParams.get("clear"); if (typeof host === "string") { const res = redirectResponse(req.nextUrl.origin); - res.cookies.set("_fern_docs_preview", host, { + res.cookies.set(COOKIE_FERN_DOCS_PREVIEW, host, { httpOnly: true, secure: false, sameSite: "lax", @@ -21,7 +22,7 @@ export default async function GET(req: NextRequest): Promise { return res; } else if (typeof site === "string") { const res = redirectResponse(req.nextUrl.origin); - res.cookies.set("_fern_docs_preview", `${site}.docs.buildwithfern.com`, { + res.cookies.set(COOKIE_FERN_DOCS_PREVIEW, `${site}.docs.buildwithfern.com`, { httpOnly: true, secure: false, sameSite: "lax", @@ -30,7 +31,7 @@ export default async function GET(req: NextRequest): Promise { return res; } else if (clear === "true") { const res = redirectResponse(req.nextUrl.origin); - res.cookies.delete("_fern_docs_preview"); + res.cookies.delete(COOKIE_FERN_DOCS_PREVIEW); return res; } diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/resolve-api.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/resolve-api.ts index f59db94726..6d8006d77b 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/resolve-api.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/resolve-api.ts @@ -5,7 +5,6 @@ import { FdrAPI } from "@fern-api/fdr-sdk"; import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; import { ApiDefinitionHolder } from "@fern-api/fdr-sdk/navigation"; import { ApiDefinitionResolver, provideRegistryService, type ResolvedRootPackage } from "@fern-ui/ui"; -import { checkViewerAllowedNode } from "@fern-ui/ui/auth"; import { getMdxBundler } from "@fern-ui/ui/bundlers"; import { NextApiHandler, NextApiResponse } from "next"; import { getFeatureFlags } from "./feature-flags"; @@ -27,17 +26,13 @@ const resolveApiHandler: NextApiHandler = async ( const xFernHost = getXFernHostNode(req); - const status = await checkViewerAllowedNode(xFernHost, req); - if (status >= 400) { - res.status(status).json(null); - return; - } - res.setHeader("host", xFernHost); const url = buildUrlFromApiNode(xFernHost, req); // eslint-disable-next-line no-console console.log("[resolve-api] Loading docs for", url); + + // we're not doing any auth here because api definitions are not authed in FDR. const docsResponse = await provideRegistryService().docs.v2.read.getDocsForUrl({ url: FdrAPI.Url(url), }); diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/revalidate-all/v3.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/revalidate-all/v3.ts index d07233d093..593fed6d76 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/revalidate-all/v3.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/revalidate-all/v3.ts @@ -1,12 +1,13 @@ import { DocsKVCache } from "@/server/DocsCache"; +import { getAuthEdgeConfig } from "@/server/auth/getAuthEdgeConfig"; import { Revalidator } from "@/server/revalidator"; +import { pruneWithBasicTokenPublic } from "@/server/withBasicTokenPublic"; import { getXFernHostNode } from "@/server/xfernhost/node"; import { FdrAPI } from "@fern-api/fdr-sdk"; import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; import { NodeCollector } from "@fern-api/fdr-sdk/navigation"; import type { FernDocs } from "@fern-fern/fern-docs-sdk"; import { provideRegistryService } from "@fern-ui/ui"; -import { getAuthEdgeConfig } from "@fern-ui/ui/auth"; import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; export const config = { @@ -37,18 +38,6 @@ const handler: NextApiHandler = async ( const revalidate = new Revalidator(res, xFernHost); try { - const authConfig = await getAuthEdgeConfig(xFernHost); - - /** - * If the auth config is basic_token_verification, we don't need to revalidate. - * - * This is because basic_token_verification is a special case where all the routes are protected by a fern_token that - * is generated by the customer, and so all routes use SSR and are not cached. - */ - if (authConfig?.type === "basic_token_verification") { - return res.status(200).json({ successfulRevalidations: [], failedRevalidations: [] }); - } - const docs = await provideRegistryService().docs.v2.read.getDocsForUrl({ url: FdrAPI.Url(xFernHost) }); if (!docs.ok) { @@ -60,7 +49,13 @@ const handler: NextApiHandler = async ( .json({ successfulRevalidations: [], failedRevalidations: [] }); } - const node = FernNavigation.utils.toRootNode(docs.body); + let node = FernNavigation.utils.toRootNode(docs.body); + + const auth = await getAuthEdgeConfig(xFernHost); + if (auth?.type === "basic_token_verification") { + node = pruneWithBasicTokenPublic(auth, node); + } + const collector = NodeCollector.collect(node); const slugs = collector.pageSlugs; diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/revalidate-all/v4.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/revalidate-all/v4.ts index 9b2750699f..645c1d0a53 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/revalidate-all/v4.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/revalidate-all/v4.ts @@ -1,12 +1,13 @@ import { DocsKVCache } from "@/server/DocsCache"; +import { getAuthEdgeConfig } from "@/server/auth/getAuthEdgeConfig"; import { Revalidator } from "@/server/revalidator"; +import { pruneWithBasicTokenPublic } from "@/server/withBasicTokenPublic"; import { getXFernHostNode } from "@/server/xfernhost/node"; import { FdrAPI } from "@fern-api/fdr-sdk"; import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; import { NodeCollector } from "@fern-api/fdr-sdk/navigation"; import type { FernDocs } from "@fern-fern/fern-docs-sdk"; import { provideRegistryService } from "@fern-ui/ui"; -import { getAuthEdgeConfig } from "@fern-ui/ui/auth"; import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; export const config = { @@ -43,24 +44,6 @@ const handler: NextApiHandler = async ( return res.status(400).json({ total: 0, results: [] }); } - try { - const authConfig = await getAuthEdgeConfig(xFernHost); - - /** - * If the auth config is basic_token_verification, we don't need to revalidate. - * - * This is because basic_token_verification is a special case where all the routes are protected by a fern_token that - * is generated by the customer, and so all routes use SSR and are not cached. - */ - if (authConfig?.type === "basic_token_verification") { - return res.status(200).json({ total: 0, results: [] }); - } - } catch (err) { - // eslint-disable-next-line no-console - console.error(err); - return res.status(500).json({ total: 0, results: [] }); - } - const docs = await provideRegistryService().docs.v2.read.getDocsForUrl({ url: FdrAPI.Url(xFernHost) }); if (!docs.ok) { @@ -70,7 +53,13 @@ const handler: NextApiHandler = async ( return res.status(docs.error.error === "UnauthorizedError" ? 200 : 404).json({ total: 0, results: [] }); } - const node = FernNavigation.utils.toRootNode(docs.body); + let node = FernNavigation.utils.toRootNode(docs.body); + + const auth = await getAuthEdgeConfig(xFernHost); + if (auth?.type === "basic_token_verification") { + node = pruneWithBasicTokenPublic(auth, node); + } + const slugs = NodeCollector.collect(node).pageSlugs; const revalidate = new Revalidator(res, xFernHost); diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/search.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/search.ts index b02c09c165..e3ff9b09e0 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/search.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/search.ts @@ -1,9 +1,10 @@ +import { checkViewerAllowedEdge } from "@/server/auth/checkViewerAllowed"; +import { getAuthEdgeConfig } from "@/server/auth/getAuthEdgeConfig"; import { loadWithUrl } from "@/server/loadWithUrl"; import { getXFernHostEdge } from "@/server/xfernhost/edge"; import { SearchConfig, getSearchConfig } from "@fern-ui/search-utils"; import { provideRegistryService } from "@fern-ui/ui"; -import { checkViewerAllowedEdge } from "@fern-ui/ui/auth"; -import * as Sentry from "@sentry/nextjs"; +import { captureException } from "@sentry/nextjs"; import { NextRequest, NextResponse } from "next/server"; export const runtime = "edge"; @@ -14,9 +15,10 @@ export default async function handler(req: NextRequest): Promise= 400) { return NextResponse.json({ isAvailable: false }, { status }); } @@ -33,7 +35,7 @@ export default async function handler(req: NextRequest): Promise { } const xFernHost = getXFernHostEdge(req); - const status = await checkViewerAllowedEdge(xFernHost, req); - if (status >= 400) { - return NextResponse.next({ status }); - } - - const headers = new Headers(); - headers.set("x-fern-host", xFernHost); + // load the root node + const fernToken = req.cookies.get(COOKIE_FERN_TOKEN)?.value; + const root = await DocsLoader.for(xFernHost, fernToken).root(); - const url = buildUrlFromApiEdge(xFernHost, req); - const docs = await loadWithUrl(url); + // collect all indexable page slugs + const slugs = NodeCollector.collect(root).indexablePageSlugs; - if (!docs.ok) { - return new NextResponse(null, { status: 404 }); - } - - const node = FernNavigation.utils.toRootNode(docs.body); - const collector = NodeCollector.collect(node); - const urls = collector.indexablePageSlugs.map((slug) => urljoin(xFernHost, slug)); + // convert slugs to full urls + const urls = slugs.map((slug) => conformTrailingSlash(urljoin(withDefaultProtocol(xFernHost), slug))); - const sitemap = getSitemapXml(urls.map((url) => conformTrailingSlash(`https://${url}`))); + // generate sitemap xml + const sitemap = getSitemapXml(urls); + const headers = new Headers(); headers.set("Content-Type", "text/xml"); return new NextResponse(sitemap, { headers }); diff --git a/packages/ui/docs-bundle/src/server/DocsLoader.ts b/packages/ui/docs-bundle/src/server/DocsLoader.ts new file mode 100644 index 0000000000..a783eb728d --- /dev/null +++ b/packages/ui/docs-bundle/src/server/DocsLoader.ts @@ -0,0 +1,105 @@ +import type { DocsV1Read, DocsV2Read } from "@fern-api/fdr-sdk"; +import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; +import type { AuthEdgeConfig, FernUser } from "@fern-ui/ui/auth"; +import { verifyFernJWTConfig } from "./auth/FernJWT"; +import { getAuthEdgeConfig } from "./auth/getAuthEdgeConfig"; +import { AuthProps } from "./authProps"; +import { loadWithUrl } from "./loadWithUrl"; +import { pruneWithBasicTokenPublic } from "./withBasicTokenPublic"; + +export class DocsLoader { + static for(xFernHost: string, fernToken: string | undefined): DocsLoader { + return new DocsLoader(xFernHost, fernToken); + } + + private constructor( + private xFernHost: string, + private fernToken: string | undefined, + ) {} + + private user: FernUser | undefined; + private auth: AuthEdgeConfig | undefined; + public withAuth(auth: AuthEdgeConfig, user: FernUser | undefined): DocsLoader { + this.auth = auth; + this.user = user; + return this; + } + + private async loadAuth(): Promise<{ + authConfig: AuthEdgeConfig | undefined; + user: FernUser | undefined; + }> { + if (!this.auth) { + this.auth = await getAuthEdgeConfig(this.xFernHost); + + try { + if (this.fernToken) { + this.user = await verifyFernJWTConfig(this.fernToken, this.auth); + } + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } + } + return { + authConfig: this.auth, + user: this.user, + }; + } + + private loadForDocsUrlResponse: DocsV2Read.LoadDocsForUrlResponse | undefined; + public withLoadDocsForUrlResponse(loadForDocsUrlResponse: DocsV2Read.LoadDocsForUrlResponse): DocsLoader { + this.loadForDocsUrlResponse = loadForDocsUrlResponse; + return this; + } + + private async loadDocs(): Promise { + if (!this.loadForDocsUrlResponse) { + const { user } = await this.loadAuth(); + const authProps: AuthProps | undefined = + user && this.fernToken ? { user, token: this.fernToken } : undefined; + + const response = await loadWithUrl(this.xFernHost, authProps); + + if (response.ok) { + this.loadForDocsUrlResponse = response.body; + } + } + return this.loadForDocsUrlResponse; + } + + public async root(): Promise { + const { authConfig, user } = await this.loadAuth(); + const docs = await this.loadDocs(); + + if (!docs) { + return undefined; + } + + let node = FernNavigation.utils.toRootNode(docs); + + // If the domain is basic_token_verification, we only want to include slugs that are allowed + if (authConfig?.type === "basic_token_verification" && !user) { + try { + // TODO: store this in cache + node = pruneWithBasicTokenPublic(authConfig, node); + } catch (e) { + return undefined; + } + } + + return node; + } + + // NOTE: authentication is based on the navigation nodes, so we don't need to check it here, + // as long as these pages are NOT shipped to the client-side. + public async pages(): Promise> { + const docs = await this.loadDocs(); + return docs?.definition.pages ?? {}; + } + + public async files(): Promise> { + const docs = await this.loadDocs(); + return docs?.definition.filesV2 ?? {}; + } +} diff --git a/packages/ui/docs-bundle/src/server/__test__/withBasicTokenViewAllowed.test.ts b/packages/ui/docs-bundle/src/server/__test__/withBasicTokenViewAllowed.test.ts new file mode 100644 index 0000000000..b555406d9e --- /dev/null +++ b/packages/ui/docs-bundle/src/server/__test__/withBasicTokenViewAllowed.test.ts @@ -0,0 +1,29 @@ +import { withBasicTokenPublic } from "../withBasicTokenPublic"; + +describe("withBasicTokenPublic", () => { + it("should deny the request if the allowlist is empty", () => { + expect(withBasicTokenPublic({}, "/public")).toBe(false); + expect(withBasicTokenPublic({ allowlist: [] }, "/public")).toBe(false); + }); + + it("should allow the request to pass through if the path is in the allowlist", () => { + expect(withBasicTokenPublic({ allowlist: ["/public"] }, "/public")).toBe(true); + }); + + it("should allow the request to pass through if the path matches a regex in the allowlist", () => { + expect(withBasicTokenPublic({ allowlist: ["/public/(.*)"] }, "/public/123")).toBe(true); + }); + + it("should allow the request to pass through if the path matches a path expression in the allowlist", () => { + expect(withBasicTokenPublic({ allowlist: ["/public/:id"] }, "/public/123")).toBe(true); + }); + + it("should not allow the request to pass through if the path is not in the allowlist", () => { + expect(withBasicTokenPublic({ allowlist: ["/public", "/public/:id"] }, "/private")).toBe(false); + expect(withBasicTokenPublic({ allowlist: ["/public", "/public/:id"] }, "/private/123")).toBe(false); + }); + + it("shouuld respect denylist before allowlist", () => { + expect(withBasicTokenPublic({ allowlist: ["/public"], denylist: ["/public"] }, "/public")).toBe(false); + }); +}); diff --git a/packages/ui/app/src/auth/FernJWT.ts b/packages/ui/docs-bundle/src/server/auth/FernJWT.ts similarity index 94% rename from packages/ui/app/src/auth/FernJWT.ts rename to packages/ui/docs-bundle/src/server/auth/FernJWT.ts index e684ba31d6..34e1bfbdc5 100644 --- a/packages/ui/app/src/auth/FernJWT.ts +++ b/packages/ui/docs-bundle/src/server/auth/FernJWT.ts @@ -1,5 +1,5 @@ +import { FernUserSchema, type AuthEdgeConfig, type FernUser } from "@fern-ui/ui/auth"; import { SignJWT, jwtVerify } from "jose"; -import { AuthEdgeConfig, FernUser, FernUserSchema } from "./types"; // "user" is reserved for workos diff --git a/packages/ui/app/src/auth/OAuth2Client.ts b/packages/ui/docs-bundle/src/server/auth/OAuth2Client.ts similarity index 97% rename from packages/ui/app/src/auth/OAuth2Client.ts rename to packages/ui/docs-bundle/src/server/auth/OAuth2Client.ts index ffa99f5402..ee36c44841 100644 --- a/packages/ui/app/src/auth/OAuth2Client.ts +++ b/packages/ui/docs-bundle/src/server/auth/OAuth2Client.ts @@ -1,8 +1,8 @@ +import { OAuthTokenResponseSchema, type AuthEdgeConfigOAuth2Ory, type OAuthTokenResponse } from "@fern-ui/ui/auth"; import { JWTPayload, createRemoteJWKSet, decodeJwt, jwtVerify } from "jose"; import { NextApiRequestCookies } from "next/dist/server/api-utils"; import type { NextRequest } from "next/server"; import urlJoin from "url-join"; -import { AuthEdgeConfigOAuth2Ory, OAuthTokenResponse, OAuthTokenResponseSchema } from "./types"; interface TokenInfo { access_token: string; diff --git a/packages/ui/app/src/auth/__test__/OAuth2Client.test.ts b/packages/ui/docs-bundle/src/server/auth/__test__/OAuth2Client.test.ts similarity index 100% rename from packages/ui/app/src/auth/__test__/OAuth2Client.test.ts rename to packages/ui/docs-bundle/src/server/auth/__test__/OAuth2Client.test.ts diff --git a/packages/ui/docs-bundle/src/server/auth/checkViewerAllowed.ts b/packages/ui/docs-bundle/src/server/auth/checkViewerAllowed.ts new file mode 100644 index 0000000000..ad93cab727 --- /dev/null +++ b/packages/ui/docs-bundle/src/server/auth/checkViewerAllowed.ts @@ -0,0 +1,33 @@ +import { AuthEdgeConfig } from "@fern-ui/ui/auth"; +import type { NextRequest } from "next/server"; +import { COOKIE_FERN_TOKEN } from "../constants"; +import { withBasicTokenPublic } from "../withBasicTokenPublic"; +import { verifyFernJWT } from "./FernJWT"; + +export async function checkViewerAllowedEdge(auth: AuthEdgeConfig | undefined, req: NextRequest): Promise { + const fernToken = req.cookies.get(COOKIE_FERN_TOKEN)?.value; + + return checkViewerAllowedPathname(auth, req.nextUrl.pathname, fernToken); +} + +export async function checkViewerAllowedPathname( + auth: AuthEdgeConfig | undefined, + pathname: string, + fernToken: string | undefined, +): Promise { + if (auth?.type === "basic_token_verification") { + if (withBasicTokenPublic(auth, pathname)) { + return 200; + } + + if (fernToken == null) { + return 401; + } else { + const verified = await verifyFernJWT(fernToken, auth.secret, auth.issuer); + if (!verified) { + return 403; + } + } + } + return 200; +} diff --git a/packages/ui/app/src/auth/getApiKeyInjectionConfig.ts b/packages/ui/docs-bundle/src/server/auth/getApiKeyInjectionConfig.ts similarity index 81% rename from packages/ui/app/src/auth/getApiKeyInjectionConfig.ts rename to packages/ui/docs-bundle/src/server/auth/getApiKeyInjectionConfig.ts index 7291f31628..2bf07a9b5e 100644 --- a/packages/ui/app/src/auth/getApiKeyInjectionConfig.ts +++ b/packages/ui/docs-bundle/src/server/auth/getApiKeyInjectionConfig.ts @@ -1,31 +1,9 @@ +import type { APIKeyInjectionConfig } from "@fern-ui/ui/auth"; import type { NextApiRequestCookies } from "next/dist/server/api-utils"; import type { NextRequest } from "next/server"; import { OAuth2Client } from "./OAuth2Client"; import { getAuthEdgeConfig } from "./getAuthEdgeConfig"; -interface APIKeyInjectionConfigDisabled { - enabled: false; -} -interface APIKeyInjectionConfigEnabledUnauthorized { - enabled: true; - authenticated: false; - url: string; - partner?: string; -} -interface APIKeyInjectionConfigEnabledAuthorized { - enabled: true; - authenticated: true; - access_token: string; - refresh_token?: string; - exp?: number; - partner?: string; -} - -export type APIKeyInjectionConfig = - | APIKeyInjectionConfigDisabled - | APIKeyInjectionConfigEnabledUnauthorized - | APIKeyInjectionConfigEnabledAuthorized; - // TODO: since this is for ORY (rightbrain) only, lets refactor export async function getAPIKeyInjectionConfig( domain: string, diff --git a/packages/ui/app/src/auth/getAuthEdgeConfig.ts b/packages/ui/docs-bundle/src/server/auth/getAuthEdgeConfig.ts similarity index 92% rename from packages/ui/app/src/auth/getAuthEdgeConfig.ts rename to packages/ui/docs-bundle/src/server/auth/getAuthEdgeConfig.ts index a75f2e1521..3f66439e03 100644 --- a/packages/ui/app/src/auth/getAuthEdgeConfig.ts +++ b/packages/ui/docs-bundle/src/server/auth/getAuthEdgeConfig.ts @@ -1,6 +1,6 @@ +import { AuthEdgeConfig, AuthEdgeConfigSchema } from "@fern-ui/ui/auth"; import { captureMessage } from "@sentry/nextjs"; import { get } from "@vercel/edge-config"; -import { AuthEdgeConfig, AuthEdgeConfigSchema } from "./types"; const KEY = "authentication"; diff --git a/packages/ui/app/src/auth/verifyAccessToken.ts b/packages/ui/docs-bundle/src/server/auth/verifyAccessToken.ts similarity index 100% rename from packages/ui/app/src/auth/verifyAccessToken.ts rename to packages/ui/docs-bundle/src/server/auth/verifyAccessToken.ts diff --git a/packages/ui/app/src/auth/withSecure.ts b/packages/ui/docs-bundle/src/server/auth/withSecure.ts similarity index 100% rename from packages/ui/app/src/auth/withSecure.ts rename to packages/ui/docs-bundle/src/server/auth/withSecure.ts diff --git a/packages/ui/docs-bundle/src/server/authProps.ts b/packages/ui/docs-bundle/src/server/authProps.ts index b68838451b..2a0ace4aaf 100644 --- a/packages/ui/docs-bundle/src/server/authProps.ts +++ b/packages/ui/docs-bundle/src/server/authProps.ts @@ -1,10 +1,10 @@ -import { getAuthEdgeConfig, verifyFernJWTConfig, type FernUser } from "@fern-ui/ui/auth"; -import type { NextApiRequestCookies } from "next/dist/server/api-utils"; +import type { FernUser } from "@fern-ui/ui/auth"; +import { verifyFernJWTConfig } from "./auth/FernJWT"; +import { getAuthEdgeConfig } from "./auth/getAuthEdgeConfig"; export interface AuthProps { token: string; user: FernUser; - cookies: NextApiRequestCookies; } /** @@ -14,18 +14,17 @@ function withPrefix(token: string, partner: FernUser["partner"]): string { return `${partner}_${token}`; } -export async function withAuthProps(xFernHost: string, cookies: NextApiRequestCookies): Promise { - if (cookies.fern_token == null) { +export async function withAuthProps(xFernHost: string, fernToken: string | null | undefined): Promise { + if (fernToken == null) { throw new Error("Missing fern_token cookie"); } const config = await getAuthEdgeConfig(xFernHost); - const user: FernUser = await verifyFernJWTConfig(cookies.fern_token, config); - const token = withPrefix(cookies.fern_token, user.partner); + const user: FernUser = await verifyFernJWTConfig(fernToken, config); + const token = withPrefix(fernToken, user.partner); const authProps: AuthProps = { token, user, - cookies, }; return authProps; diff --git a/packages/ui/docs-bundle/src/server/constants.ts b/packages/ui/docs-bundle/src/server/constants.ts index 1634e08981..df40d52bc8 100644 --- a/packages/ui/docs-bundle/src/server/constants.ts +++ b/packages/ui/docs-bundle/src/server/constants.ts @@ -1,6 +1,8 @@ export const TRACK_LOAD_DOCS_PERFORMANCE = "load_docs_performance" as const; export const COOKIE_FERN_DOCS_PREVIEW = "_fern_docs_preview" as const; export const COOKIE_FERN_TOKEN = "fern_token" as const; +export const COOKIE_ACCESS_TOKEN = "access_token" as const; // for api key injection +export const COOKIE_REFRESH_TOKEN = "refresh_token" as const; // for api key injection export const HEADER_X_FERN_HOST = "x-fern-host" as const; /** diff --git a/packages/ui/docs-bundle/src/server/getDocsPageProps.ts b/packages/ui/docs-bundle/src/server/getDocsPageProps.ts index 65a22bd985..cde6679c14 100644 --- a/packages/ui/docs-bundle/src/server/getDocsPageProps.ts +++ b/packages/ui/docs-bundle/src/server/getDocsPageProps.ts @@ -1,6 +1,7 @@ -import { type DocsPage } from "@fern-ui/ui"; +import type { DocsPage } from "@fern-ui/ui"; import type { FernUser } from "@fern-ui/ui/auth"; import type { GetServerSidePropsResult } from "next"; +import type { NextApiRequestCookies } from "next/dist/server/api-utils"; import type { ComponentProps } from "react"; import { LoadDocsPerformanceTracker } from "./LoadDocsPerformanceTracker"; import type { AuthProps } from "./authProps"; @@ -18,6 +19,7 @@ export async function getDocsPageProps( xFernHost: string | undefined, slug: string[], auth?: AuthProps, + cookies?: NextApiRequestCookies, ): Promise { if (xFernHost == null || Array.isArray(xFernHost)) { return { notFound: true }; @@ -33,7 +35,9 @@ export async function getDocsPageProps( /** * Convert the docs into initial props for the page. */ - const initialProps = await performance.trackInitialPropsPromise(withInitialProps({ docs, slug, xFernHost, auth })); + const initialProps = await performance.trackInitialPropsPromise( + withInitialProps({ docs, slug, xFernHost, auth, cookies }), + ); /** * Send performance data to Vercel Analytics. diff --git a/packages/ui/docs-bundle/src/server/getDynamicDocsPageProps.ts b/packages/ui/docs-bundle/src/server/getDynamicDocsPageProps.ts index 4b6d32afd3..3d3ac79a84 100644 --- a/packages/ui/docs-bundle/src/server/getDynamicDocsPageProps.ts +++ b/packages/ui/docs-bundle/src/server/getDynamicDocsPageProps.ts @@ -3,6 +3,7 @@ import type { NextApiRequestCookies } from "next/dist/server/api-utils"; import type { GetServerSidePropsResult } from "next/types"; import type { ComponentProps } from "react"; import { withAuthProps } from "./authProps"; +import { COOKIE_FERN_TOKEN } from "./constants"; import { getDocsPageProps } from "./getDocsPageProps"; type GetServerSideDocsPagePropsResult = GetServerSidePropsResult>; @@ -12,7 +13,7 @@ export async function getDynamicDocsPageProps( slug: string[], cookies: NextApiRequestCookies, ): Promise { - if (cookies.fern_token == null) { + if (cookies[COOKIE_FERN_TOKEN] == null) { /** * this only happens when ?error=true is passed in the URL * Note: custom auth (via edge config) is supported via middleware, so we don't need to handle it here @@ -24,6 +25,6 @@ export async function getDynamicDocsPageProps( * Authenticated user is guaranteed to have a valid token because the middleware * would have redirected them to the login page */ - const authProps = await withAuthProps(xFernHost, cookies); - return getDocsPageProps(xFernHost, slug, authProps); + const authProps = await withAuthProps(xFernHost, cookies[COOKIE_FERN_TOKEN]); + return getDocsPageProps(xFernHost, slug, authProps, cookies); } diff --git a/packages/ui/docs-bundle/src/server/withBasicTokenPublic.ts b/packages/ui/docs-bundle/src/server/withBasicTokenPublic.ts new file mode 100644 index 0000000000..68b3d616a6 --- /dev/null +++ b/packages/ui/docs-bundle/src/server/withBasicTokenPublic.ts @@ -0,0 +1,45 @@ +import { RootNode, isPage, utils } from "@fern-api/fdr-sdk/navigation"; +import { matchPath } from "@fern-ui/fern-docs-utils"; +import { AuthEdgeConfigBasicTokenVerification } from "@fern-ui/ui/auth"; +import { captureMessage } from "@sentry/nextjs"; + +/** + * @param auth Basic token verification configuration + * @param pathname pathname of the request to check + * @returns true if the request is allowed to pass through, false otherwise + */ +export function withBasicTokenPublic( + auth: Pick, + pathname: string, +): boolean { + // if the path is in the denylist, deny the request + if (auth.denylist?.find((path) => matchPath(path, pathname))) { + return false; + } + + // if the path is in the allowlist, allow the request to pass through + if (auth.allowlist?.find((path) => matchPath(path, pathname))) { + return true; + } + + // if the path is not in the allowlist, deny the request + return false; +} + +export function pruneWithBasicTokenPublic(auth: AuthEdgeConfigBasicTokenVerification, node: RootNode): RootNode { + const result = utils.pruneNavigationTree(node, (node) => { + if (isPage(node)) { + return withBasicTokenPublic(auth, `/${node.slug}`); + } + + return true; + }); + + // TODO: handle this more gracefully + if (result == null) { + captureMessage("Failed to prune navigation tree", "fatal"); + throw new Error("Failed to prune navigation tree"); + } + + return result; +} diff --git a/packages/ui/docs-bundle/src/server/withInitialProps.ts b/packages/ui/docs-bundle/src/server/withInitialProps.ts index 753da02163..66af07486a 100644 --- a/packages/ui/docs-bundle/src/server/withInitialProps.ts +++ b/packages/ui/docs-bundle/src/server/withInitialProps.ts @@ -1,5 +1,6 @@ import { getFeatureFlags } from "@/pages/api/fern-docs/feature-flags"; import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; +import { withDefaultProtocol } from "@fern-ui/core-utils"; import visitDiscriminatedUnion from "@fern-ui/core-utils/visitDiscriminatedUnion"; import { SidebarTab } from "@fern-ui/fdr-utils"; import { getRedirectForPath } from "@fern-ui/fern-docs-utils"; @@ -14,17 +15,20 @@ import { renderThemeStylesheet, resolveDocsContent, } from "@fern-ui/ui"; -import { getAPIKeyInjectionConfigNode } from "@fern-ui/ui/auth"; import { getMdxBundler } from "@fern-ui/ui/bundlers"; import { GetServerSidePropsResult } from "next"; +import type { NextApiRequestCookies } from "next/dist/server/api-utils"; import { ComponentProps } from "react"; import urlJoin from "url-join"; +import { getAPIKeyInjectionConfigNode } from "./auth/getApiKeyInjectionConfig"; +import { getAuthEdgeConfig } from "./auth/getAuthEdgeConfig"; import type { AuthProps } from "./authProps"; import { getSeoDisabled } from "./disabledSeo"; import { getCustomerAnalytics } from "./getCustomerAnalytics"; import { handleLoadDocsError } from "./handleLoadDocsError"; import type { LoadWithUrlResponse } from "./loadWithUrl"; import { isTrailingSlashEnabled } from "./trailingSlash"; +import { pruneWithBasicTokenPublic } from "./withBasicTokenPublic"; import { withVersionSwitcherInfo } from "./withVersionSwitcherInfo"; interface WithInitialProps { @@ -32,6 +36,7 @@ interface WithInitialProps { slug: string[]; xFernHost: string; auth?: AuthProps; + cookies?: NextApiRequestCookies; } export async function withInitialProps({ @@ -39,6 +44,7 @@ export async function withInitialProps({ slug: slugArray, xFernHost, auth, + cookies, }: WithInitialProps): Promise>> { if (!docsResponse.ok) { return handleLoadDocsError(xFernHost, slugArray, docsResponse.error); @@ -57,11 +63,27 @@ export async function withInitialProps({ } const featureFlags = await getFeatureFlags(xFernHost); - const root = FernNavigation.utils.toRootNode( + + const original: FernNavigation.RootNode = FernNavigation.utils.toRootNode( docs, featureFlags.isBatchStreamToggleDisabled, featureFlags.isApiScrollingDisabled, ); + let root: FernNavigation.RootNode | null = original; + + const authConfig = await getAuthEdgeConfig(xFernHost); + + // if the user is not authenticated, and the page requires authentication, prune the navigation tree + // to only show pages that are allowed to be viewed without authentication. + // note: the middleware will not show this page at all if the user is not authenticated. + if (authConfig?.type === "basic_token_verification" && auth == null) { + root = pruneWithBasicTokenPublic(authConfig, root); + } + + // this should not happen, but if it does, we should return a 404 + if (root == null) { + return { notFound: true }; + } // if the root has a slug and the current slug is empty, redirect to the root slug, rather than 404 if (root.slug.length > 0 && slug.length === 0) { @@ -76,6 +98,15 @@ export async function withInitialProps({ const node = FernNavigation.utils.findNode(root, slug); if (node.type === "notFound") { + // this is a special case where the user is not authenticated, and the page requires authentication, + // but the user is trying to access a page that is not found. in this case, we should redirect to the auth page. + if (authConfig?.type === "basic_token_verification" && auth == null) { + const node = FernNavigation.utils.findNode(original, slug); + if (node.type !== "notFound") { + return { redirect: { destination: authConfig.redirect, permanent: false } }; + } + } + // TODO: returning "notFound: true" here will render vercel's default 404 page // this is better than following redirects, since it will signal a proper 404 status code. // however, we should consider rendering a custom 404 page in the future using the customer's branding. @@ -121,6 +152,11 @@ export async function withInitialProps({ return { notFound: true }; } + const getApiRoute = getApiRouteSupplier({ + basepath: docs.baseUrl.basePath, + includeTrailingSlash: isTrailingSlashEnabled(), + }); + const colors = { light: docs.definition.config.colorsV3?.type === "light" @@ -147,6 +183,34 @@ export async function withInitialProps({ docs.definition.config.logoHref ?? (node.landingPage?.slug != null && !node.landingPage.hidden ? `/${node.landingPage.slug}` : undefined); + const navbarLinks = docs.definition.config.navbarLinks ?? []; + + // TODO: This is a hack to add a login/logout button to the navbar. This should be done in a more generic way. + if (authConfig?.type === "basic_token_verification") { + if (auth == null) { + const redirect = new URL(withDefaultProtocol(authConfig.redirect)); + redirect.searchParams.set("state", urlJoin(withDefaultProtocol(xFernHost), slug)); + + navbarLinks.push({ + type: "outlined", + text: "Login", + url: FernNavigation.Url(redirect.toString()), + icon: undefined, + rightIcon: undefined, + rounded: false, + }); + } else { + navbarLinks.push({ + type: "outlined", + text: "Logout", + url: FernNavigation.Url(getApiRoute("/api/fern-docs/auth/logout")), + icon: undefined, + rightIcon: undefined, + rounded: false, + }); + } + } + const props: ComponentProps = { baseUrl: docs.baseUrl, layout: docs.definition.config.layout, @@ -154,7 +218,7 @@ export async function withInitialProps({ favicon: docs.definition.config.favicon, colors, js: docs.definition.config.js, - navbarLinks: docs.definition.config.navbarLinks ?? [], + navbarLinks, logoHeight: docs.definition.config.logoHeight, logoHref: logoHref != null ? FernNavigation.Url(logoHref) : undefined, files: docs.definition.filesV2, @@ -228,11 +292,6 @@ export async function withInitialProps({ ), }; - const getApiRoute = getApiRouteSupplier({ - basepath: docs.baseUrl.basePath, - includeTrailingSlash: isTrailingSlashEnabled(), - }); - props.fallback[getApiRoute("/api/fern-docs/search")] = await getSearchConfig( provideRegistryService(), xFernHost, @@ -251,7 +310,7 @@ export async function withInitialProps({ } } - const apiKeyInjectionConfig = await getAPIKeyInjectionConfigNode(xFernHost, auth?.cookies); + const apiKeyInjectionConfig = await getAPIKeyInjectionConfigNode(xFernHost, cookies); props.fallback[getApiRoute("/api/fern-docs/auth/api-key-injection")] = apiKeyInjectionConfig; return { diff --git a/packages/ui/fern-docs-utils/src/getRedirectForPath.ts b/packages/ui/fern-docs-utils/src/getRedirectForPath.ts index 1dd9f6e507..931b9053b1 100644 --- a/packages/ui/fern-docs-utils/src/getRedirectForPath.ts +++ b/packages/ui/fern-docs-utils/src/getRedirectForPath.ts @@ -4,22 +4,29 @@ import type { Redirect } from "next/types"; import { compile, match } from "path-to-regexp"; import urljoin from "url-join"; -function safeMatch(source: string, path: string): ReturnType> { - if (source === path) { +/** + * Match a path against a pattern, wrapped in a try-catch block to prevent crashes + * + * @param pattern path should follow path-to-regexp@6 syntax + * @param path the current path to match against + * @returns false if the path does not match the pattern, otherwise an object with the params and the path + */ +export function matchPath(pattern: string, path: string): ReturnType> { + if (pattern === path) { return { params: {}, path, index: 0 }; } try { - return match(source)(path); + return match(pattern)(path); } catch (e) { // eslint-disable-next-line no-console - console.error(e, { source, path }); + console.error(e, { pattern, path }); return false; } } function safeCompile( destination: string, - match: Exclude, false>, + match: Exclude, false>, ): ReturnType> { try { return compile(destination)(match.params); @@ -37,7 +44,7 @@ export function getRedirectForPath( ): { redirect: Redirect } | undefined { for (const redirect of redirects) { const source = removeTrailingSlash(withBasepath(redirect.source, baseUrl.basePath)); - const result = safeMatch(source, pathWithoutBasepath); + const result = matchPath(source, pathWithoutBasepath); if (result) { const destination = safeCompile(redirect.destination, result); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57061474be..cca4b26f4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -817,6 +817,9 @@ importers: '@fern-ui/core-utils': specifier: workspace:* version: link:../commons/core-utils + '@ungap/structured-clone': + specifier: ^1.2.0 + version: 1.2.0 dayjs: specifier: ^1.11.11 version: 1.11.11 @@ -881,6 +884,9 @@ importers: '@types/title': specifier: ^3.4.3 version: 3.4.3 + '@types/ungap__structured-clone': + specifier: ^1.2.0 + version: 1.2.0 eslint: specifier: ^8.56.0 version: 8.57.0 @@ -1127,9 +1133,6 @@ importers: '@types/nprogress': specifier: ^0.2.3 version: 0.2.3 - '@vercel/edge-config': - specifier: ^1.1.0 - version: 1.1.0(@opentelemetry/api@1.9.0)(typescript@5.4.3) algoliasearch: specifier: ^4.24.0 version: 4.24.0 @@ -1820,6 +1823,9 @@ importers: httpsnippet-lite: specifier: ^3.0.5 version: 3.0.5 + jose: + specifier: ^5.2.3 + version: 5.2.4 jsonpath: specifier: ^1.1.1 version: 1.1.1 @@ -7551,6 +7557,9 @@ packages: '@types/ua-parser-js@0.7.39': resolution: {integrity: sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==} + '@types/ungap__structured-clone@1.2.0': + resolution: {integrity: sha512-ZoaihZNLeZSxESbk9PUAPZOlSpcKx81I1+4emtULDVmBLkYutTcMlCj2K9VNlf9EWODxdO6gkAqEaLorXwZQVA==} + '@types/unist@2.0.10': resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==} @@ -17012,10 +17021,10 @@ snapshots: '@aws-crypto/sha1-browser': 3.0.0 '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.572.0(@aws-sdk/client-sts@3.572.0) - '@aws-sdk/client-sts': 3.572.0 + '@aws-sdk/client-sso-oidc': 3.572.0 + '@aws-sdk/client-sts': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0) '@aws-sdk/core': 3.572.0 - '@aws-sdk/credential-provider-node': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0(@aws-sdk/client-sts@3.572.0))(@aws-sdk/client-sts@3.572.0) + '@aws-sdk/credential-provider-node': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0)(@aws-sdk/client-sts@3.572.0(@aws-sdk/client-sso-oidc@3.572.0)) '@aws-sdk/middleware-bucket-endpoint': 3.568.0 '@aws-sdk/middleware-expect-continue': 3.572.0 '@aws-sdk/middleware-flexible-checksums': 3.572.0 @@ -17076,7 +17085,7 @@ snapshots: '@aws-crypto/sha256-js': 3.0.0 '@aws-sdk/client-sts': 3.572.0 '@aws-sdk/core': 3.572.0 - '@aws-sdk/credential-provider-node': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0)(@aws-sdk/client-sts@3.572.0) + '@aws-sdk/credential-provider-node': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0)(@aws-sdk/client-sts@3.572.0(@aws-sdk/client-sso-oidc@3.572.0)) '@aws-sdk/middleware-host-header': 3.567.0 '@aws-sdk/middleware-logger': 3.568.0 '@aws-sdk/middleware-recursion-detection': 3.567.0 @@ -17210,7 +17219,52 @@ snapshots: '@aws-crypto/sha256-js': 3.0.0 '@aws-sdk/client-sso-oidc': 3.572.0 '@aws-sdk/core': 3.572.0 - '@aws-sdk/credential-provider-node': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0(@aws-sdk/client-sts@3.572.0))(@aws-sdk/client-sts@3.572.0) + '@aws-sdk/credential-provider-node': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0)(@aws-sdk/client-sts@3.572.0) + '@aws-sdk/middleware-host-header': 3.567.0 + '@aws-sdk/middleware-logger': 3.568.0 + '@aws-sdk/middleware-recursion-detection': 3.567.0 + '@aws-sdk/middleware-user-agent': 3.572.0 + '@aws-sdk/region-config-resolver': 3.572.0 + '@aws-sdk/types': 3.567.0 + '@aws-sdk/util-endpoints': 3.572.0 + '@aws-sdk/util-user-agent-browser': 3.567.0 + '@aws-sdk/util-user-agent-node': 3.568.0 + '@smithy/config-resolver': 2.2.0 + '@smithy/core': 1.4.2 + '@smithy/fetch-http-handler': 2.5.0 + '@smithy/hash-node': 2.2.0 + '@smithy/invalid-dependency': 2.2.0 + '@smithy/middleware-content-length': 2.2.0 + '@smithy/middleware-endpoint': 2.5.1 + '@smithy/middleware-retry': 2.3.1 + '@smithy/middleware-serde': 2.3.0 + '@smithy/middleware-stack': 2.2.0 + '@smithy/node-config-provider': 2.3.0 + '@smithy/node-http-handler': 2.5.0 + '@smithy/protocol-http': 3.3.0 + '@smithy/smithy-client': 2.5.1 + '@smithy/types': 2.12.0 + '@smithy/url-parser': 2.2.0 + '@smithy/util-base64': 2.3.0 + '@smithy/util-body-length-browser': 2.2.0 + '@smithy/util-body-length-node': 2.3.0 + '@smithy/util-defaults-mode-browser': 2.2.1 + '@smithy/util-defaults-mode-node': 2.3.1 + '@smithy/util-endpoints': 1.2.0 + '@smithy/util-middleware': 2.2.0 + '@smithy/util-retry': 2.2.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.6.2 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-sts@3.572.0(@aws-sdk/client-sso-oidc@3.572.0)': + dependencies: + '@aws-crypto/sha256-browser': 3.0.0 + '@aws-crypto/sha256-js': 3.0.0 + '@aws-sdk/client-sso-oidc': 3.572.0 + '@aws-sdk/core': 3.572.0 + '@aws-sdk/credential-provider-node': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0)(@aws-sdk/client-sts@3.572.0(@aws-sdk/client-sso-oidc@3.572.0)) '@aws-sdk/middleware-host-header': 3.567.0 '@aws-sdk/middleware-logger': 3.568.0 '@aws-sdk/middleware-recursion-detection': 3.567.0 @@ -17247,6 +17301,7 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.6.2 transitivePeerDependencies: + - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/core@3.572.0': @@ -17295,6 +17350,23 @@ snapshots: - '@aws-sdk/client-sso-oidc' - aws-crt + '@aws-sdk/credential-provider-ini@3.572.0(@aws-sdk/client-sso-oidc@3.572.0)(@aws-sdk/client-sts@3.572.0(@aws-sdk/client-sso-oidc@3.572.0))': + dependencies: + '@aws-sdk/client-sts': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0) + '@aws-sdk/credential-provider-env': 3.568.0 + '@aws-sdk/credential-provider-process': 3.572.0 + '@aws-sdk/credential-provider-sso': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0) + '@aws-sdk/credential-provider-web-identity': 3.568.0(@aws-sdk/client-sts@3.572.0) + '@aws-sdk/types': 3.567.0 + '@smithy/credential-provider-imds': 2.3.0 + '@smithy/property-provider': 2.2.0 + '@smithy/shared-ini-file-loader': 2.4.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + transitivePeerDependencies: + - '@aws-sdk/client-sso-oidc' + - aws-crt + '@aws-sdk/credential-provider-ini@3.572.0(@aws-sdk/client-sso-oidc@3.572.0)(@aws-sdk/client-sts@3.572.0)': dependencies: '@aws-sdk/client-sts': 3.572.0 @@ -17331,6 +17403,25 @@ snapshots: - '@aws-sdk/client-sts' - aws-crt + '@aws-sdk/credential-provider-node@3.572.0(@aws-sdk/client-sso-oidc@3.572.0)(@aws-sdk/client-sts@3.572.0(@aws-sdk/client-sso-oidc@3.572.0))': + dependencies: + '@aws-sdk/credential-provider-env': 3.568.0 + '@aws-sdk/credential-provider-http': 3.568.0 + '@aws-sdk/credential-provider-ini': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0)(@aws-sdk/client-sts@3.572.0(@aws-sdk/client-sso-oidc@3.572.0)) + '@aws-sdk/credential-provider-process': 3.572.0 + '@aws-sdk/credential-provider-sso': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0) + '@aws-sdk/credential-provider-web-identity': 3.568.0(@aws-sdk/client-sts@3.572.0) + '@aws-sdk/types': 3.567.0 + '@smithy/credential-provider-imds': 2.3.0 + '@smithy/property-provider': 2.2.0 + '@smithy/shared-ini-file-loader': 2.4.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + transitivePeerDependencies: + - '@aws-sdk/client-sso-oidc' + - '@aws-sdk/client-sts' + - aws-crt + '@aws-sdk/credential-provider-node@3.572.0(@aws-sdk/client-sso-oidc@3.572.0)(@aws-sdk/client-sts@3.572.0)': dependencies: '@aws-sdk/credential-provider-env': 3.568.0 @@ -17386,7 +17477,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.568.0(@aws-sdk/client-sts@3.572.0)': dependencies: - '@aws-sdk/client-sts': 3.572.0 + '@aws-sdk/client-sts': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0) '@aws-sdk/types': 3.567.0 '@smithy/property-provider': 2.2.0 '@smithy/types': 2.12.0 @@ -24040,6 +24131,8 @@ snapshots: '@types/ua-parser-js@0.7.39': {} + '@types/ungap__structured-clone@1.2.0': {} + '@types/unist@2.0.10': {} '@types/unist@3.0.2': {} @@ -25699,7 +25792,7 @@ snapshots: agent-base@7.1.1: dependencies: - debug: 4.3.6(supports-color@8.1.1) + debug: 4.3.7 transitivePeerDependencies: - supports-color @@ -26573,7 +26666,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 @@ -29278,7 +29371,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 @@ -29318,7 +29411,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 @@ -34362,7 +34455,7 @@ snapshots: stylus@0.62.0: dependencies: '@adobe/css-tools': 4.3.3 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.7 glob: 7.2.3 sax: 1.3.0 source-map: 0.7.4 diff --git a/servers/fdr/src/services/algolia/AlgoliaSearchRecordGenerator.ts b/servers/fdr/src/services/algolia/AlgoliaSearchRecordGenerator.ts index ea437e6bcd..3fc5bba119 100644 --- a/servers/fdr/src/services/algolia/AlgoliaSearchRecordGenerator.ts +++ b/servers/fdr/src/services/algolia/AlgoliaSearchRecordGenerator.ts @@ -252,7 +252,7 @@ export class AlgoliaSearchRecordGenerator { } satisfies Algolia.AlgoliaRecordVersionV3) : undefined; - function toBreadcrumbs(parents: FernNavigation.V1.NavigationNode[]): string[] { + function toBreadcrumbs(parents: readonly FernNavigation.V1.NavigationNode[]): string[] { return [ ...breadcrumbs, ...parents @@ -268,7 +268,7 @@ export class AlgoliaSearchRecordGenerator { ]; } - FernNavigation.V1.traverseNavigation(root, (node, _index, parents) => { + FernNavigation.V1.traverseDF(root, (node, parents) => { if (!FernNavigation.V1.hasMetadata(node)) { return; } @@ -698,7 +698,7 @@ export class AlgoliaSearchRecordGenerator { } : undefined; - function toBreadcrumbs(parents: FernNavigation.V1.NavigationNode[]): string[] { + function toBreadcrumbs(parents: readonly FernNavigation.V1.NavigationNode[]): string[] { return [ ...breadcrumbs, ...parents @@ -714,7 +714,7 @@ export class AlgoliaSearchRecordGenerator { ]; } - FernNavigation.V1.traverseNavigation(root, (node, _index, parents) => { + FernNavigation.V1.traverseDF(root, (node, parents) => { if (!FernNavigation.V1.hasMetadata(node)) { return; } diff --git a/servers/fdr/src/services/algolia/AlgoliaSearchRecordGeneratorV2.ts b/servers/fdr/src/services/algolia/AlgoliaSearchRecordGeneratorV2.ts index a8e0e52536..78390b5c27 100644 --- a/servers/fdr/src/services/algolia/AlgoliaSearchRecordGeneratorV2.ts +++ b/servers/fdr/src/services/algolia/AlgoliaSearchRecordGeneratorV2.ts @@ -467,7 +467,7 @@ export class AlgoliaSearchRecordGeneratorV2 extends AlgoliaSearchRecordGenerator } satisfies Algolia.AlgoliaRecordVersionV3) : undefined; - FernNavigation.V1.traverseNavigation(root, (node, _index, parents) => { + FernNavigation.V1.traverseDF(root, (node, parents) => { if (!FernNavigation.V1.hasMetadata(node)) { return; } @@ -1359,7 +1359,7 @@ export class AlgoliaSearchRecordGeneratorV2 extends AlgoliaSearchRecordGenerator slug: part.urlSlug, })); - FernNavigation.V1.traverseNavigation(root, (node, _index, parents) => { + FernNavigation.V1.traverseDF(root, (node) => { if (!FernNavigation.V1.hasMetadata(node)) { return; } @@ -1449,7 +1449,7 @@ function toBreadcrumbs( title: string; slug: string; }[], - parents: FernNavigation.V1.NavigationNode[], + parents: readonly FernNavigation.V1.NavigationNode[], ): BreadcrumbsInfo[] { return [ ...breadcrumbs,