From 349de32c3a6c8b50e334c0e849c4c6c65121b794 Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Wed, 9 Oct 2024 20:11:28 -0400 Subject: [PATCH] feat: isUnathenticatedPagesDiscoverable feature flag --- packages/fdr-sdk/src/navigation/index.ts | 1 + .../__test__/pruneNavigationTree.test.ts | 26 +++-- .../fdr-sdk/src/navigation/utils/index.ts | 1 - .../navigation/utils/pruneNavigationTree.ts | 98 +++++++++++-------- packages/ui/app/src/sidebar/SidebarLink.tsx | 14 ++- .../src/sidebar/nodes/SidebarApiLeafNode.tsx | 1 + .../sidebar/nodes/SidebarApiPackageNode.tsx | 2 + .../sidebar/nodes/SidebarChangelogNode.tsx | 1 + .../app/src/sidebar/nodes/SidebarLinkNode.tsx | 12 ++- .../app/src/sidebar/nodes/SidebarPageNode.tsx | 1 + .../nodes/SidebarRootApiPackageNode.tsx | 1 + .../src/sidebar/nodes/SidebarRootHeading.tsx | 1 + .../sidebar/nodes/SidebarRootSectionNode.tsx | 1 + .../src/sidebar/nodes/SidebarSectionNode.tsx | 1 + packages/ui/docs-bundle/src/middleware.ts | 2 +- .../withBasicTokenViewAllowed.test.ts | 24 ++--- .../src/server/withBasicTokenAnonymous.ts | 30 +++--- .../src/server/withInitialProps.ts | 32 ++++-- .../src/getFeatureFlags.ts | 7 ++ packages/ui/fern-docs-utils/src/flags.ts | 2 + 20 files changed, 171 insertions(+), 87 deletions(-) diff --git a/packages/fdr-sdk/src/navigation/index.ts b/packages/fdr-sdk/src/navigation/index.ts index 9f69acc109..b7ade428d6 100644 --- a/packages/fdr-sdk/src/navigation/index.ts +++ b/packages/fdr-sdk/src/navigation/index.ts @@ -7,5 +7,6 @@ export * from "../client/generated/api/resources/commons"; export * from "./ApiDefinitionHolder"; export * as migrate from "./migrators"; export * as utils from "./utils"; +export * from "./utils/pruneNavigationTree"; export * from "./versions"; export { ApiDefinitionHolder, ApiDefinitionPruner, ApiTypeIdVisitor, NodeCollector }; diff --git a/packages/fdr-sdk/src/navigation/utils/__test__/pruneNavigationTree.test.ts b/packages/fdr-sdk/src/navigation/utils/__test__/pruneNavigationTree.test.ts index 2caec70270..11fd326b6c 100644 --- a/packages/fdr-sdk/src/navigation/utils/__test__/pruneNavigationTree.test.ts +++ b/packages/fdr-sdk/src/navigation/utils/__test__/pruneNavigationTree.test.ts @@ -1,5 +1,5 @@ import { FernNavigation } from "../../.."; -import { pruneNavigationTree } from "../pruneNavigationTree"; +import { Pruner } from "../pruneNavigationTree"; describe("pruneNavigationTree", () => { it("should not prune the tree if keep returns true for all nodes", () => { @@ -32,7 +32,9 @@ describe("pruneNavigationTree", () => { pointsTo: undefined, }; - const result = pruneNavigationTree(root, () => true); + const result = Pruner.from(root) + .keep(() => true) + .get(); // structuredClone should duplicate the object expect(result === root).toBe(false); @@ -97,7 +99,9 @@ describe("pruneNavigationTree", () => { pointsTo: FernNavigation.Slug("root/page"), }; - const result = pruneNavigationTree(root, (node) => node.id !== FernNavigation.NodeId("page")); + const result = Pruner.from(root) + .keep((node) => node.id !== FernNavigation.NodeId("page")) + .get(); expect(result).toBeUndefined(); }); @@ -132,7 +136,9 @@ describe("pruneNavigationTree", () => { pointsTo: undefined, }; - const result = pruneNavigationTree(root, (node) => node.id !== "root"); + const result = Pruner.from(root) + .keep((node) => node.id !== "root") + .get(); // structuredClone should duplicate the object expect(result === root).toBe(false); @@ -197,7 +203,9 @@ describe("pruneNavigationTree", () => { pointsTo: undefined, }; - const result = pruneNavigationTree(root, (node) => node.id !== "page"); + const result = Pruner.from(root) + .keep((node) => node.id !== "page") + .get(); // structuredClone should duplicate the object expect(result === root).toBe(false); @@ -249,7 +257,9 @@ describe("pruneNavigationTree", () => { pointsTo: undefined, }; - const result = pruneNavigationTree(root, (node) => node.id !== "root"); + const result = Pruner.from(root) + .keep((node) => node.id !== "root") + .get(); // structuredClone should duplicate the object expect(result === root).toBe(false); @@ -342,7 +352,9 @@ describe("pruneNavigationTree", () => { pointsTo: undefined, }; - const result = pruneNavigationTree(root, (node) => node.id !== "page1"); + const result = Pruner.from(root) + .keep((node) => node.id !== "page1") + .get(); // structuredClone should duplicate the object expect(result === root).toBe(false); diff --git a/packages/fdr-sdk/src/navigation/utils/index.ts b/packages/fdr-sdk/src/navigation/utils/index.ts index b1e2b704f8..44f3d27e67 100644 --- a/packages/fdr-sdk/src/navigation/utils/index.ts +++ b/packages/fdr-sdk/src/navigation/utils/index.ts @@ -4,6 +4,5 @@ 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 index 9bc3c8ac81..f361b0f96e 100644 --- a/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts +++ b/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts @@ -1,55 +1,71 @@ import structuredClone from "@ungap/structured-clone"; -import { DeepReadonly } from "ts-essentials"; import { FernNavigation } from "../.."; import { bfs } from "../../utils/traversers/bfs"; import { prunetree } from "../../utils/traversers/prunetree"; import { mutableDeleteChild } from "./deleteChild"; 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 - * @param hide a function that returns true if the node should be hidden - * @returns a new navigation tree with only the nodes that should be kept - */ -export function pruneNavigationTree( - root: DeepReadonly, - keep?: (node: FernNavigation.NavigationNode) => boolean, - hide?: (node: FernNavigation.NavigationNodeWithMetadata) => boolean, -): ROOT | undefined { - const clone = structuredClone(root) as ROOT; - return mutablePruneNavigationTree(clone, keep, hide); -} +export class Pruner { + public static from(tree: ROOT): Pruner { + return new Pruner(tree); + } -function mutablePruneNavigationTree( - root: ROOT, - keep: (node: FernNavigation.NavigationNode) => boolean = () => true, - hide: (node: FernNavigation.NavigationNodeWithMetadata) => boolean = () => false, -): ROOT | undefined { - const [result] = prunetree(root, { - predicate: keep, - getChildren: FernNavigation.getChildren, - getPointer: (node) => node.id, - deleter: mutableDeleteChild, - }); + private tree: ROOT | undefined; + private constructor(tree: ROOT) { + this.tree = structuredClone(tree) as ROOT; + } - if (result == null) { - return undefined; + public keep(predicate: (node: FernNavigation.NavigationNode) => boolean): this { + if (this.tree == null) { + return this; + } + const [result] = prunetree(this.tree, { + predicate, + getChildren: FernNavigation.getChildren, + getPointer: (node) => node.id, + deleter: mutableDeleteChild, + }); + this.tree = result; + return this; } - // since the tree has been pruned, we need to update the pointsTo property - mutableUpdatePointsTo(result); + public hide(predicate: (node: FernNavigation.NavigationNodeWithMetadata) => boolean): this { + if (this.tree == null) { + return this; + } + bfs( + this.tree, + (node) => { + if (FernNavigation.hasMarkdown(node) && predicate(node)) { + node.hidden = true; + } + }, + FernNavigation.getChildren, + ); + return this; + } - // other operations - bfs( - result, - (node) => { - if (FernNavigation.hasMarkdown(node) && hide(node)) { - node.hidden = true; - } - }, - FernNavigation.getChildren, - ); + public authed(predicate: (node: FernNavigation.NavigationNodeWithMetadata) => boolean): this { + if (this.tree == null) { + return this; + } + bfs( + this.tree, + (node) => { + if (FernNavigation.hasMarkdown(node) && predicate(node)) { + node.authed = true; + } + }, + FernNavigation.getChildren, + ); + return this; + } - return result; + public get(): ROOT | undefined { + if (this.tree == null) { + return undefined; + } + mutableUpdatePointsTo(this.tree); + return this.tree; + } } diff --git a/packages/ui/app/src/sidebar/SidebarLink.tsx b/packages/ui/app/src/sidebar/SidebarLink.tsx index 90aabbc203..a9f8a96393 100644 --- a/packages/ui/app/src/sidebar/SidebarLink.tsx +++ b/packages/ui/app/src/sidebar/SidebarLink.tsx @@ -1,7 +1,7 @@ import type * as FernNavigation from "@fern-api/fdr-sdk/navigation"; import { FernTooltip, RemoteFontAwesomeIcon } from "@fern-ui/components"; import cn, { clsx } from "clsx"; -import { NavArrowDown } from "iconoir-react"; +import { Lock, NavArrowDown } from "iconoir-react"; import { range } from "lodash-es"; import { Url } from "next/dist/shared/lib/router/router"; import { @@ -42,6 +42,7 @@ interface SidebarSlugLinkProps { rightElement?: ReactNode; tooltipContent?: ReactNode; hidden?: boolean; + authed?: boolean; as?: keyof JSX.IntrinsicElements | JSXElementConstructor; } @@ -76,6 +77,7 @@ const SidebarLinkInternal = forwardRef((props, target, rel, hidden, + authed, as = "span", } = props; @@ -119,6 +121,14 @@ const SidebarLinkInternal = forwardRef((props, }; const withTooltip = (content: ReactNode) => { + if (authed) { + return ( + + {content} + + ); + } + if (tooltipContent == null) { return content; } @@ -172,7 +182,7 @@ const SidebarLinkInternal = forwardRef((props, )} {createElement(as, { className: "fern-sidebar-link-text" }, title)} - {rightElement} + {authed ? : rightElement} {expandButton} , diff --git a/packages/ui/app/src/sidebar/nodes/SidebarApiLeafNode.tsx b/packages/ui/app/src/sidebar/nodes/SidebarApiLeafNode.tsx index 5592fda2b2..601634e4f5 100644 --- a/packages/ui/app/src/sidebar/nodes/SidebarApiLeafNode.tsx +++ b/packages/ui/app/src/sidebar/nodes/SidebarApiLeafNode.tsx @@ -35,6 +35,7 @@ export function SidebarApiLeafNode({ node, depth, shallow }: SidebarApiLeafNodeP title={node.title} depth={Math.max(0, depth - 1)} hidden={node.hidden} + authed={node.authed} icon={renderRightElement()} selected={selected} shallow={shallow} diff --git a/packages/ui/app/src/sidebar/nodes/SidebarApiPackageNode.tsx b/packages/ui/app/src/sidebar/nodes/SidebarApiPackageNode.tsx index b0ff8e79e4..ac04752f96 100644 --- a/packages/ui/app/src/sidebar/nodes/SidebarApiPackageNode.tsx +++ b/packages/ui/app/src/sidebar/nodes/SidebarApiPackageNode.tsx @@ -51,6 +51,7 @@ export function SidebarApiPackageNode({ slug={node.slug} depth={Math.max(depth - 1, 0)} title={node.title} + authed={node.authed} selected={selected} icon={node.icon} hidden={node.hidden} @@ -93,6 +94,7 @@ export function SidebarApiPackageNode({ toggleExpand={handleToggleExpand} showIndicator={showIndicator} hidden={node.hidden} + authed={node.authed} slug={node.overviewPageId != null ? node.slug : undefined} selected={selected} shallow={shallow} diff --git a/packages/ui/app/src/sidebar/nodes/SidebarChangelogNode.tsx b/packages/ui/app/src/sidebar/nodes/SidebarChangelogNode.tsx index ffe320d3eb..5ae38051c1 100644 --- a/packages/ui/app/src/sidebar/nodes/SidebarChangelogNode.tsx +++ b/packages/ui/app/src/sidebar/nodes/SidebarChangelogNode.tsx @@ -28,6 +28,7 @@ export function SidebarChangelogNode({ node, depth, className }: SidebarChangelo icon={node.icon ?? } tooltipContent={renderChangelogTooltip(node)} hidden={node.hidden} + authed={node.authed} /> ); } diff --git a/packages/ui/app/src/sidebar/nodes/SidebarLinkNode.tsx b/packages/ui/app/src/sidebar/nodes/SidebarLinkNode.tsx index fcfad1739e..6e1109a886 100644 --- a/packages/ui/app/src/sidebar/nodes/SidebarLinkNode.tsx +++ b/packages/ui/app/src/sidebar/nodes/SidebarLinkNode.tsx @@ -1,5 +1,6 @@ import type * as FernNavigation from "@fern-api/fdr-sdk/navigation"; import { OpenNewWindow } from "iconoir-react"; +import { useEffect, useState } from "react"; import { SidebarLink } from "../SidebarLink"; interface SidebarLinkNodeProps { @@ -9,6 +10,12 @@ interface SidebarLinkNodeProps { } export function SidebarLinkNode({ node, depth, className }: SidebarLinkNodeProps): React.ReactElement { + // TODO: handle this more gracefully, and make this SSG-friendly + const [origin, setOrigin] = useState("xxx"); + useEffect(() => { + setOrigin(window.location.origin); + }, []); + return ( } + rightElement={ + node.url.startsWith("http") && + !node.url.startsWith(origin) && + } href={node.url} /> ); diff --git a/packages/ui/app/src/sidebar/nodes/SidebarPageNode.tsx b/packages/ui/app/src/sidebar/nodes/SidebarPageNode.tsx index 288602b864..73cda0fd29 100644 --- a/packages/ui/app/src/sidebar/nodes/SidebarPageNode.tsx +++ b/packages/ui/app/src/sidebar/nodes/SidebarPageNode.tsx @@ -26,6 +26,7 @@ export function SidebarPageNode({ node, depth, className, shallow }: SidebarPage selected={selected} icon={node.icon} hidden={node.hidden} + authed={node.authed} shallow={shallow} /> ); diff --git a/packages/ui/app/src/sidebar/nodes/SidebarRootApiPackageNode.tsx b/packages/ui/app/src/sidebar/nodes/SidebarRootApiPackageNode.tsx index 5f19f66179..9f0f2f0861 100644 --- a/packages/ui/app/src/sidebar/nodes/SidebarRootApiPackageNode.tsx +++ b/packages/ui/app/src/sidebar/nodes/SidebarRootApiPackageNode.tsx @@ -37,6 +37,7 @@ export function SidebarRootApiPackageNode({ selected={selected} icon={node.icon} hidden={node.hidden} + authed={node.authed} shallow={shallow} /> ); diff --git a/packages/ui/app/src/sidebar/nodes/SidebarRootHeading.tsx b/packages/ui/app/src/sidebar/nodes/SidebarRootHeading.tsx index 126cf3959b..996e8d1d99 100644 --- a/packages/ui/app/src/sidebar/nodes/SidebarRootHeading.tsx +++ b/packages/ui/app/src/sidebar/nodes/SidebarRootHeading.tsx @@ -31,6 +31,7 @@ export function SidebarRootHeading({ node, className, shallow }: SidebarRootHead className={className} title={node.title} hidden={node.hidden} + authed={node.authed} slug={node.slug} selected={selected} shallow={shallow} diff --git a/packages/ui/app/src/sidebar/nodes/SidebarRootSectionNode.tsx b/packages/ui/app/src/sidebar/nodes/SidebarRootSectionNode.tsx index a01964d98e..636dd5d342 100644 --- a/packages/ui/app/src/sidebar/nodes/SidebarRootSectionNode.tsx +++ b/packages/ui/app/src/sidebar/nodes/SidebarRootSectionNode.tsx @@ -33,6 +33,7 @@ export function SidebarRootSectionNode({ node, className }: SidebarRootSectionNo selected={selected} icon={node.icon} hidden={node.hidden} + authed={node.authed} /> ); } diff --git a/packages/ui/app/src/sidebar/nodes/SidebarSectionNode.tsx b/packages/ui/app/src/sidebar/nodes/SidebarSectionNode.tsx index 9012f076f3..84f1329b91 100644 --- a/packages/ui/app/src/sidebar/nodes/SidebarSectionNode.tsx +++ b/packages/ui/app/src/sidebar/nodes/SidebarSectionNode.tsx @@ -62,6 +62,7 @@ export function SidebarSectionNode({ node, className, depth }: SidebarSectionNod toggleExpand={handleToggleExpand} showIndicator={showIndicator} hidden={node.hidden} + authed={node.authed} slug={node.overviewPageId != null ? node.slug : undefined} selected={selected} /> diff --git a/packages/ui/docs-bundle/src/middleware.ts b/packages/ui/docs-bundle/src/middleware.ts index 071684029d..d26edf488b 100644 --- a/packages/ui/docs-bundle/src/middleware.ts +++ b/packages/ui/docs-bundle/src/middleware.ts @@ -96,7 +96,7 @@ export const middleware: NextMiddleware = async (request) => { * redirect to the custom auth provider */ if (!isLoggedIn && authConfig?.type === "basic_token_verification") { - if (!withBasicTokenAnonymous(authConfig, pathname)) { + if (withBasicTokenAnonymous(authConfig, pathname)) { const destination = new URL(authConfig.redirect); destination.searchParams.set("state", urlJoin(withDefaultProtocol(xFernHost), pathname)); // TODO: validate allowlist of domains to prevent open redirects diff --git a/packages/ui/docs-bundle/src/server/__test__/withBasicTokenViewAllowed.test.ts b/packages/ui/docs-bundle/src/server/__test__/withBasicTokenViewAllowed.test.ts index 5d1a6df08e..fb1f9af211 100644 --- a/packages/ui/docs-bundle/src/server/__test__/withBasicTokenViewAllowed.test.ts +++ b/packages/ui/docs-bundle/src/server/__test__/withBasicTokenViewAllowed.test.ts @@ -3,29 +3,29 @@ import { withBasicTokenAnonymous, withBasicTokenAnonymousCheck } from "../withBa describe("withBasicTokenAnonymous", () => { it("should deny the request if the allowlist is empty", () => { - expect(withBasicTokenAnonymous({}, "/public")).toBe(false); - expect(withBasicTokenAnonymous({ allowlist: [] }, "/public")).toBe(false); + expect(withBasicTokenAnonymous({}, "/public")).toBe(true); + expect(withBasicTokenAnonymous({ allowlist: [] }, "/public")).toBe(true); }); it("should allow the request to pass through if the path is in the allowlist", () => { - expect(withBasicTokenAnonymous({ allowlist: ["/public"] }, "/public")).toBe(true); + expect(withBasicTokenAnonymous({ allowlist: ["/public"] }, "/public")).toBe(false); }); it("should allow the request to pass through if the path matches a regex in the allowlist", () => { - expect(withBasicTokenAnonymous({ allowlist: ["/public/(.*)"] }, "/public/123")).toBe(true); + expect(withBasicTokenAnonymous({ allowlist: ["/public/(.*)"] }, "/public/123")).toBe(false); }); it("should allow the request to pass through if the path matches a path expression in the allowlist", () => { - expect(withBasicTokenAnonymous({ allowlist: ["/public/:id"] }, "/public/123")).toBe(true); + expect(withBasicTokenAnonymous({ allowlist: ["/public/:id"] }, "/public/123")).toBe(false); }); it("should not allow the request to pass through if the path is not in the allowlist", () => { - expect(withBasicTokenAnonymous({ allowlist: ["/public", "/public/:id"] }, "/private")).toBe(false); - expect(withBasicTokenAnonymous({ allowlist: ["/public", "/public/:id"] }, "/private/123")).toBe(false); + expect(withBasicTokenAnonymous({ allowlist: ["/public", "/public/:id"] }, "/private")).toBe(true); + expect(withBasicTokenAnonymous({ allowlist: ["/public", "/public/:id"] }, "/private/123")).toBe(true); }); it("shouuld respect denylist before allowlist", () => { - expect(withBasicTokenAnonymous({ allowlist: ["/public"], denylist: ["/public"] }, "/public")).toBe(false); + expect(withBasicTokenAnonymous({ allowlist: ["/public"], denylist: ["/public"] }, "/public")).toBe(true); }); it("should never deny external links", () => { @@ -37,7 +37,7 @@ describe("withBasicTokenAnonymous", () => { icon: undefined, id: NodeId("1"), }), - ).toBe(true); + ).toBe(false); }); it("should prune childless non-leaf nodes", () => { @@ -52,11 +52,12 @@ describe("withBasicTokenAnonymous", () => { canonicalSlug: undefined, icon: undefined, hidden: undefined, + authed: undefined, overviewPageId: undefined, noindex: undefined, pointsTo: undefined, }), - ).toBe(false); + ).toBe(true); }); it("should not prune childless non-leaf nodes that have content", () => { @@ -71,10 +72,11 @@ describe("withBasicTokenAnonymous", () => { canonicalSlug: undefined, icon: undefined, hidden: undefined, + authed: undefined, overviewPageId: PageId("1.mdx"), noindex: undefined, pointsTo: undefined, }), - ).toBe(true); + ).toBe(false); }); }); diff --git a/packages/ui/docs-bundle/src/server/withBasicTokenAnonymous.ts b/packages/ui/docs-bundle/src/server/withBasicTokenAnonymous.ts index 5e4d36af24..ee7abd0c36 100644 --- a/packages/ui/docs-bundle/src/server/withBasicTokenAnonymous.ts +++ b/packages/ui/docs-bundle/src/server/withBasicTokenAnonymous.ts @@ -1,11 +1,11 @@ -import { getChildren, isLeaf, isPage, utils, type NavigationNode, type RootNode } from "@fern-api/fdr-sdk/navigation"; +import { Pruner, isPage, type NavigationNode, type RootNode } from "@fern-api/fdr-sdk/navigation"; import type { AuthEdgeConfigBasicTokenVerification } from "@fern-ui/fern-docs-auth"; import { matchPath } from "@fern-ui/fern-docs-utils"; /** * @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 + * @returns true if the request should should be marked as authed */ export function withBasicTokenAnonymous( auth: Pick, @@ -13,7 +13,7 @@ export function withBasicTokenAnonymous( ): boolean { // if the path is in the denylist, deny the request if (auth.denylist?.find((path) => matchPath(path, pathname))) { - return false; + return true; } // if the path is in the allowlist, allow the request to pass through @@ -21,11 +21,11 @@ export function withBasicTokenAnonymous( auth.allowlist?.find((path) => matchPath(path, pathname)) || auth.anonymous?.find((path) => matchPath(path, pathname)) ) { - return true; + return false; } // if the path is not in the allowlist, deny the request - return false; + return true; } /** @@ -37,16 +37,17 @@ export function withBasicTokenAnonymousCheck( return (node: NavigationNode) => { if (isPage(node)) { return withBasicTokenAnonymous(auth, `/${node.slug}`); - } else if (!isLeaf(node) && getChildren(node).length === 0) { - return false; } - return true; + return false; }; } export function pruneWithBasicTokenAnonymous(auth: AuthEdgeConfigBasicTokenVerification, node: RootNode): RootNode { - const result = utils.pruneNavigationTree(node, withBasicTokenAnonymousCheck(auth)); + const result = Pruner.from(node) + // mark nodes that are authed + .authed(withBasicTokenAnonymousCheck(auth)) + .get(); // TODO: handle this more gracefully if (result == null) { @@ -57,13 +58,10 @@ export function pruneWithBasicTokenAnonymous(auth: AuthEdgeConfigBasicTokenVerif } export function pruneWithBasicTokenAuthed(auth: AuthEdgeConfigBasicTokenVerification, node: RootNode): RootNode { - const result = utils.pruneNavigationTree( - node, - // do not delete any nodes - () => true, - // hide nodes that are in the anonymous list - (n) => auth.anonymous?.find((path) => matchPath(path, `/${n.slug}`)) != null, - ); + const result = Pruner.from(node) + // hide nodes that are not authed + .hide((n) => auth.anonymous?.find((path) => matchPath(path, `/${n.slug}`)) != null) + .get(); if (result == null) { throw new Error("Failed to prune navigation tree"); diff --git a/packages/ui/docs-bundle/src/server/withInitialProps.ts b/packages/ui/docs-bundle/src/server/withInitialProps.ts index c790783ea7..6a17c84bf2 100644 --- a/packages/ui/docs-bundle/src/server/withInitialProps.ts +++ b/packages/ui/docs-bundle/src/server/withInitialProps.ts @@ -202,15 +202,33 @@ export async function withInitialProps({ } } - // prune the navigation tree to remove hidden nodes, unless it is the current node const sidebar = node.sidebar != null - ? FernNavigation.utils.pruneNavigationTree(node.sidebar, (n) => { - if (FernNavigation.hasMetadata(n) && n.hidden) { - return n.id === node.node.id; - } - return true; - }) + ? FernNavigation.Pruner.from(node.sidebar) + .keep((n) => { + // prune hidden nodes, unless it is the current node + if (FernNavigation.hasMetadata(n) && n.hidden) { + return n.id === node.node.id; + } + + // prune authenticated pages + if ( + FernNavigation.hasMetadata(n) && + n.authed && + auth == null && + !featureFlags.isUnathenticatedPagesDiscoverable + ) { + return false; + } + + // prune nodes that are not pages and have no children (avoid pruning links) + if (!FernNavigation.isPage(n) && !FernNavigation.isLeaf(n)) { + return FernNavigation.getChildren(n).length > 0; + } + + return true; + }) + .get() : undefined; const props: ComponentProps = { diff --git a/packages/ui/fern-docs-edge-config/src/getFeatureFlags.ts b/packages/ui/fern-docs-edge-config/src/getFeatureFlags.ts index c4c0886ffc..b3a829c1c5 100644 --- a/packages/ui/fern-docs-edge-config/src/getFeatureFlags.ts +++ b/packages/ui/fern-docs-edge-config/src/getFeatureFlags.ts @@ -34,6 +34,7 @@ const FEATURE_FLAGS = [ "hide-404-page" as const, "new-search-experience" as const, "grpc-endpoints" as const, + "authenticated-pages-discoverable" as const, ]; type FeatureFlag = (typeof FEATURE_FLAGS)[number]; @@ -87,6 +88,10 @@ export async function getFeatureFlags(domain: string): Promise { const isFileForgeHackEnabled = checkDomainMatchesCustomers(domain, config["file-forge-hack-enabled"]); const is404PageHidden = checkDomainMatchesCustomers(domain, config["hide-404-page"]); const isNewSearchExperienceEnabled = checkDomainMatchesCustomers(domain, config["new-search-experience"]); + const isUnathenticatedPagesDiscoverable = checkDomainMatchesCustomers( + domain, + config["authenticated-pages-discoverable"], + ); const grpcEndpoints = config["grpc-endpoints"]; return { @@ -118,6 +123,7 @@ export async function getFeatureFlags(domain: string): Promise { isFileForgeHackEnabled, is404PageHidden, isNewSearchExperienceEnabled, + isUnathenticatedPagesDiscoverable, grpcEndpoints, }; } catch (e) { @@ -152,6 +158,7 @@ export async function getFeatureFlags(domain: string): Promise { isFileForgeHackEnabled: false, is404PageHidden: false, isNewSearchExperienceEnabled: false, + isUnathenticatedPagesDiscoverable: false, grpcEndpoints: [], }; } diff --git a/packages/ui/fern-docs-utils/src/flags.ts b/packages/ui/fern-docs-utils/src/flags.ts index 8cf47cde66..1775ebbcad 100644 --- a/packages/ui/fern-docs-utils/src/flags.ts +++ b/packages/ui/fern-docs-utils/src/flags.ts @@ -27,6 +27,7 @@ export interface FeatureFlags { isFileForgeHackEnabled: boolean; is404PageHidden: boolean; isNewSearchExperienceEnabled: boolean; + isUnathenticatedPagesDiscoverable: boolean; // TODO: remove this after pinecone demo, this is a temporary flag grpcEndpoints: readonly string[]; } @@ -60,6 +61,7 @@ export const DEFAULT_FEATURE_FLAGS: FeatureFlags = { isFileForgeHackEnabled: false, is404PageHidden: false, isNewSearchExperienceEnabled: false, + isUnathenticatedPagesDiscoverable: false, // TODO: remove this after pinecone demo, this is a temporary flag grpcEndpoints: [], };