From 054532ba4237e1d24b410146381e403397a465a3 Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Thu, 3 Oct 2024 18:11:42 -0400 Subject: [PATCH 01/27] allowlist --- packages/ui/app/src/atoms/auth.ts | 2 +- packages/ui/app/src/atoms/types.ts | 12 +-- packages/ui/app/src/auth/index.ts | 12 +-- packages/ui/app/src/auth/injection.ts | 32 ++++++ packages/ui/app/src/auth/types.ts | 6 ++ packages/ui/docs-bundle/package.json | 1 + packages/ui/docs-bundle/src/middleware.ts | 13 ++- .../[api]/endpoint/[endpoint].ts | 2 +- .../api-definition/[api]/webhook/[webhook].ts | 2 +- .../[api]/websocket/[websocket].ts | 2 +- .../api/fern-docs/auth/api-key-injection.ts | 13 +-- .../src/pages/api/fern-docs/auth/callback.ts | 5 +- .../src/pages/api/fern-docs/changelog.ts | 2 +- .../pages/api/fern-docs/oauth/ory/callback.ts | 13 +-- .../api/fern-docs/oauth/webflow/callback.ts | 3 +- .../src/pages/api/fern-docs/resolve-api.ts | 2 +- .../pages/api/fern-docs/revalidate-all/v3.ts | 39 +++++--- .../pages/api/fern-docs/revalidate-all/v4.ts | 35 ++++--- .../src/pages/api/fern-docs/search.ts | 2 +- .../src/pages/api/fern-docs/sitemap.xml.ts | 2 +- .../withBasicTokenViewAllowed.test.ts | 25 +++++ .../src/server}/auth/FernJWT.ts | 2 +- .../src/server}/auth/OAuth2Client.ts | 2 +- .../auth/__test__/OAuth2Client.test.ts | 0 .../src/server}/auth/checkViewerAllowed.ts | 15 +++ .../server}/auth/getApiKeyInjectionConfig.ts | 24 +---- .../src/server}/auth/getAuthEdgeConfig.ts | 2 +- .../src/server}/auth/verifyAccessToken.ts | 0 .../src/server}/auth/withSecure.ts | 0 .../ui/docs-bundle/src/server/authProps.ts | 4 +- .../src/server/getDocsPageProps.ts | 2 +- .../src/server/withBasicTokenViewAllowed.ts | 14 +++ .../src/server/withInitialProps.ts | 2 +- .../fern-docs-utils/src/getRedirectForPath.ts | 19 ++-- pnpm-lock.yaml | 97 +++++++++++++++++-- 35 files changed, 291 insertions(+), 117 deletions(-) create mode 100644 packages/ui/app/src/auth/injection.ts create mode 100644 packages/ui/docs-bundle/src/server/__test__/withBasicTokenViewAllowed.test.ts rename packages/ui/{app/src => docs-bundle/src/server}/auth/FernJWT.ts (94%) rename packages/ui/{app/src => docs-bundle/src/server}/auth/OAuth2Client.ts (97%) rename packages/ui/{app/src => docs-bundle/src/server}/auth/__test__/OAuth2Client.test.ts (100%) rename packages/ui/{app/src => docs-bundle/src/server}/auth/checkViewerAllowed.ts (70%) rename packages/ui/{app/src => docs-bundle/src/server}/auth/getApiKeyInjectionConfig.ts (81%) rename packages/ui/{app/src => docs-bundle/src/server}/auth/getAuthEdgeConfig.ts (92%) rename packages/ui/{app/src => docs-bundle/src/server}/auth/verifyAccessToken.ts (100%) rename packages/ui/{app/src => docs-bundle/src/server}/auth/withSecure.ts (100%) create mode 100644 packages/ui/docs-bundle/src/server/withBasicTokenViewAllowed.ts 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/types.ts b/packages/ui/app/src/atoms/types.ts index 8d4a478df8..e90bfb2713 100644 --- a/packages/ui/app/src/atoms/types.ts +++ b/packages/ui/app/src/atoms/types.ts @@ -1,12 +1,12 @@ import type { DocsV1Read, DocsV2Read, FdrAPI } from "@fern-api/fdr-sdk/client/types"; import type * as FernDocs from "@fern-api/fdr-sdk/docs"; import type * as FernNavigation from "@fern-api/fdr-sdk/navigation"; -import { ColorsConfig, SidebarTab, SidebarVersionInfo } from "@fern-ui/fdr-utils"; -import { NextSeoProps } from "@fern-ui/next-seo"; -import { CustomerAnalytics } from "../analytics/types"; -import { FernUser } from "../auth"; -import { DocsContent } from "../resolver/DocsContent"; -import { FernTheme } from "../themes/ThemedDocs"; +import type { ColorsConfig, SidebarTab, SidebarVersionInfo } from "@fern-ui/fdr-utils"; +import type { NextSeoProps } from "@fern-ui/next-seo"; +import type { CustomerAnalytics } from "../analytics/types"; +import type { FernUser } from "../auth"; +import type { DocsContent } from "../resolver/DocsContent"; +import type { FernTheme } from "../themes/ThemedDocs"; export interface FeatureFlags { isApiPlaygroundEnabled: boolean; 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..00ba27e944 100644 --- a/packages/ui/app/src/auth/types.ts +++ b/packages/ui/app/src/auth/types.ts @@ -34,6 +34,12 @@ export const AuthEdgeConfigBasicTokenVerificationSchema = z.object({ secret: z.string(), issuer: z.string(), redirect: z.string(), + + allowlist: z + .array(z.string(), { + description: "List of pages that are public and do not require authentication", + }) + .optional(), }); export const AuthEdgeConfigSchema = z.union([ 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..af1df20990 100644 --- a/packages/ui/docs-bundle/src/middleware.ts +++ b/packages/ui/docs-bundle/src/middleware.ts @@ -2,10 +2,13 @@ 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 { withBasicTokenViewAllowed } from "./server/withBasicTokenViewAllowed"; const API_FERN_DOCS_PATTERN = /^(?!\/api\/fern-docs\/).*(\/api\/fern-docs\/)/; const CHANGELOG_PATTERN = /\.(rss|atom)$/; @@ -98,9 +101,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 (!withBasicTokenViewAllowed(authConfig.allowlist, pathname)) { + const destination = new URL(authConfig.redirect); + destination.searchParams.set("state", urlJoin(`https://${xFernHost}`, pathname)); + return NextResponse.redirect(destination, { status: 302 }); + } } /** 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..a89044fdb5 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,7 @@ import { ApiDefinitionLoader } from "@/server/ApiDefinitionLoader"; +import { checkViewerAllowedNode } from "@/server/auth/checkViewerAllowed"; 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"; 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..57fd15fb5a 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,7 @@ import { ApiDefinitionLoader } from "@/server/ApiDefinitionLoader"; +import { checkViewerAllowedNode } from "@/server/auth/checkViewerAllowed"; 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"; 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..416ff59c9b 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,7 @@ import { ApiDefinitionLoader } from "@/server/ApiDefinitionLoader"; +import { checkViewerAllowedNode } from "@/server/auth/checkViewerAllowed"; 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"; 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..29f5292056 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,9 @@ +import { OAuth2Client } from "@/server/auth/OAuth2Client"; +import { APIKeyInjectionConfig, getAPIKeyInjectionConfig } from "@/server/auth/getApiKeyInjectionConfig"; +import { getAuthEdgeConfig } from "@/server/auth/getAuthEdgeConfig"; +import { withSecureCookie } from "@/server/auth/withSecure"; import { getXFernHostEdge } from "@/server/xfernhost/edge"; -import { - APIKeyInjectionConfig, - OAuth2Client, - OryAccessTokenSchema, - getAPIKeyInjectionConfig, - getAuthEdgeConfig, - withSecureCookie, -} from "@fern-ui/ui/auth"; +import { 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"; diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/callback.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/callback.ts index f2a6fe86ef..2a5f14c548 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/callback.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/callback.ts @@ -1,6 +1,9 @@ +import { signFernJWT } from "@/server/auth/FernJWT"; +import { getAuthEdgeConfig } from "@/server/auth/getAuthEdgeConfig"; +import { withSecureCookie } from "@/server/auth/withSecure"; import { getWorkOS, getWorkOSClientId } from "@/server/workos"; import { getXFernHostEdge } from "@/server/xfernhost/edge"; -import { FernUser, getAuthEdgeConfig, signFernJWT, withSecureCookie } from "@fern-ui/ui/auth"; +import { FernUser } from "@fern-ui/ui/auth"; import { NextRequest, NextResponse } from "next/server"; export const runtime = "edge"; 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..5150648bf0 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,3 +1,4 @@ +import { checkViewerAllowedNode } from "@/server/auth/checkViewerAllowed"; import { buildUrlFromApiNode } from "@/server/buildUrlFromApi"; import { loadWithUrl } from "@/server/loadWithUrl"; import { getXFernHostNode } from "@/server/xfernhost/node"; @@ -6,7 +7,6 @@ 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"; 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..218790124f 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,9 @@ +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 { 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"; 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/resolve-api.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/resolve-api.ts index f59db94726..d1ede64d98 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 @@ -1,11 +1,11 @@ import { DocsKVCache } from "@/server/DocsCache"; +import { checkViewerAllowedNode } from "@/server/auth/checkViewerAllowed"; import { buildUrlFromApiNode } from "@/server/buildUrlFromApi"; import { getXFernHostNode } from "@/server/xfernhost/node"; 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"; 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..aebe2ce316 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 { withBasicTokenViewAllowed } from "@/server/withBasicTokenViewAllowed"; 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 = { @@ -33,22 +34,27 @@ const handler: NextApiHandler = async ( res: NextApiResponse, ): Promise => { const xFernHost = getXFernHostNode(req, true); + const authConfig = await getAuthEdgeConfig(xFernHost); + + let allowlist: string[] = []; + + /** + * 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") { + if (authConfig.allowlist == null || authConfig.allowlist.length === 0) { + return res.status(200).json({ successfulRevalidations: [], failedRevalidations: [] }); + } else { + allowlist = authConfig.allowlist; + } + } 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) { @@ -62,7 +68,12 @@ const handler: NextApiHandler = async ( const node = FernNavigation.utils.toRootNode(docs.body); const collector = NodeCollector.collect(node); - const slugs = collector.pageSlugs; + let slugs = collector.pageSlugs; + + // if the allowlist is nonempty, it means some of the routes are protected by basic_token_verification + if (allowlist.length > 0) { + slugs = slugs.filter((slug) => withBasicTokenViewAllowed(allowlist, `/${slug}`)); + } const cache = DocsKVCache.getInstance(xFernHost); const previouslyVisitedSlugs = (await cache.getVisitedSlugs()).filter((slug) => !slugs.includes(slug)); 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..1a0e8c1d63 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 { withBasicTokenViewAllowed } from "@/server/withBasicTokenViewAllowed"; 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 = { @@ -21,6 +22,7 @@ const handler: NextApiHandler = async ( res: NextApiResponse, ): Promise => { const xFernHost = getXFernHostNode(req, true); + const authConfig = await getAuthEdgeConfig(xFernHost); /** * Limit the number of paths to revalidate to max of 100. @@ -43,22 +45,20 @@ const handler: NextApiHandler = async ( return res.status(400).json({ total: 0, results: [] }); } - try { - const authConfig = await getAuthEdgeConfig(xFernHost); + let allowlist: string[] = []; - /** - * 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") { + /** + * 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") { + if (authConfig.allowlist == null || authConfig.allowlist.length === 0) { return res.status(200).json({ total: 0, results: [] }); + } else { + allowlist = authConfig.allowlist; } - } 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) }); @@ -84,7 +84,12 @@ const handler: NextApiHandler = async ( const total = slugs.length; const start = offset * limit; - const batch = slugs.slice(start, start + limit); + let batch = slugs.slice(start, start + limit); + + // if the allowlist is nonempty, it means some of the routes are protected by basic_token_verification + if (allowlist.length > 0) { + batch = batch.filter((slug) => withBasicTokenViewAllowed(allowlist, `/${slug}`)); + } const results = await revalidate.batch(batch); 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..fda8604c30 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,8 +1,8 @@ +import { checkViewerAllowedEdge } from "@/server/auth/checkViewerAllowed"; 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 { NextRequest, NextResponse } from "next/server"; diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/sitemap.xml.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/sitemap.xml.ts index d88b61025e..25b4ed87a4 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/sitemap.xml.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/sitemap.xml.ts @@ -1,10 +1,10 @@ +import { checkViewerAllowedEdge } from "@/server/auth/checkViewerAllowed"; import { buildUrlFromApiEdge } from "@/server/buildUrlFromApi"; import { loadWithUrl } from "@/server/loadWithUrl"; import { conformTrailingSlash } from "@/server/trailingSlash"; import { getXFernHostEdge } from "@/server/xfernhost/edge"; import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; import { NodeCollector } from "@fern-api/fdr-sdk/navigation"; -import { checkViewerAllowedEdge } from "@fern-ui/ui/auth"; import { NextRequest, NextResponse } from "next/server"; import urljoin from "url-join"; 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..267e164b7a --- /dev/null +++ b/packages/ui/docs-bundle/src/server/__test__/withBasicTokenViewAllowed.test.ts @@ -0,0 +1,25 @@ +import { withBasicTokenViewAllowed } from "../withBasicTokenViewAllowed"; + +describe("withBasicTokenViewAllowed", () => { + it("should deny the request if the allowlist is empty", () => { + expect(withBasicTokenViewAllowed(undefined, "/public")).toBe(false); + expect(withBasicTokenViewAllowed([], "/public")).toBe(false); + }); + + it("should allow the request to pass through if the path is in the allowlist", () => { + expect(withBasicTokenViewAllowed(["/public"], "/public")).toBe(true); + }); + + it("should allow the request to pass through if the path matches a regex in the allowlist", () => { + expect(withBasicTokenViewAllowed(["/public/(.*)"], "/public/123")).toBe(true); + }); + + it("should allow the request to pass through if the path matches a path expression in the allowlist", () => { + expect(withBasicTokenViewAllowed(["/public/:id"], "/public/123")).toBe(true); + }); + + it("should not allow the request to pass through if the path is not in the allowlist", () => { + expect(withBasicTokenViewAllowed(["/public", "/public/:id"], "/private")).toBe(false); + expect(withBasicTokenViewAllowed(["/public", "/public/:id"], "/private/123")).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/app/src/auth/checkViewerAllowed.ts b/packages/ui/docs-bundle/src/server/auth/checkViewerAllowed.ts similarity index 70% rename from packages/ui/app/src/auth/checkViewerAllowed.ts rename to packages/ui/docs-bundle/src/server/auth/checkViewerAllowed.ts index 45b39ef5e0..24e8614c90 100644 --- a/packages/ui/app/src/auth/checkViewerAllowed.ts +++ b/packages/ui/docs-bundle/src/server/auth/checkViewerAllowed.ts @@ -1,5 +1,7 @@ +import { captureException } from "@sentry/nextjs"; import type { NextApiRequest } from "next"; import type { NextRequest } from "next/server"; +import { withBasicTokenViewAllowed } from "../withBasicTokenViewAllowed"; import { verifyFernJWT } from "./FernJWT"; import { getAuthEdgeConfig } from "./getAuthEdgeConfig"; @@ -8,6 +10,10 @@ export async function checkViewerAllowedEdge(domain: string, req: NextRequest): const fern_token = req.cookies.get("fern_token")?.value; if (auth?.type === "basic_token_verification") { + if (withBasicTokenViewAllowed(auth.allowlist, req.nextUrl.pathname)) { + return 200; + } + if (fern_token == null) { return 401; } else { @@ -25,6 +31,15 @@ export async function checkViewerAllowedNode(domain: string, req: NextApiRequest const fern_token = req.cookies.fern_token; if (auth?.type === "basic_token_verification") { + try { + if (req.url && withBasicTokenViewAllowed(auth.allowlist, new URL(req.url).pathname)) { + return 200; + } + } catch (e) { + // something went wrong with the URL parsing + captureException(e); + } + if (fern_token == null) { return 401; } else { 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..fe89c1c9e2 100644 --- a/packages/ui/docs-bundle/src/server/authProps.ts +++ b/packages/ui/docs-bundle/src/server/authProps.ts @@ -1,5 +1,7 @@ -import { getAuthEdgeConfig, verifyFernJWTConfig, type FernUser } from "@fern-ui/ui/auth"; +import type { FernUser } from "@fern-ui/ui/auth"; import type { NextApiRequestCookies } from "next/dist/server/api-utils"; +import { verifyFernJWTConfig } from "./auth/FernJWT"; +import { getAuthEdgeConfig } from "./auth/getAuthEdgeConfig"; export interface AuthProps { token: string; diff --git a/packages/ui/docs-bundle/src/server/getDocsPageProps.ts b/packages/ui/docs-bundle/src/server/getDocsPageProps.ts index 65a22bd985..83261c3d01 100644 --- a/packages/ui/docs-bundle/src/server/getDocsPageProps.ts +++ b/packages/ui/docs-bundle/src/server/getDocsPageProps.ts @@ -1,4 +1,4 @@ -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 { ComponentProps } from "react"; diff --git a/packages/ui/docs-bundle/src/server/withBasicTokenViewAllowed.ts b/packages/ui/docs-bundle/src/server/withBasicTokenViewAllowed.ts new file mode 100644 index 0000000000..5e956ae75e --- /dev/null +++ b/packages/ui/docs-bundle/src/server/withBasicTokenViewAllowed.ts @@ -0,0 +1,14 @@ +import { matchPath } from "@fern-ui/fern-docs-utils"; + +/** + * @param config 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 withBasicTokenViewAllowed(allowlist: string[] = [], pathname: string): boolean { + // if the path is in the allowlist, allow the request to pass through + if (allowlist.find((path) => matchPath(path, pathname))) { + return true; + } + return false; +} diff --git a/packages/ui/docs-bundle/src/server/withInitialProps.ts b/packages/ui/docs-bundle/src/server/withInitialProps.ts index d1a46399e8..4bc7666083 100644 --- a/packages/ui/docs-bundle/src/server/withInitialProps.ts +++ b/packages/ui/docs-bundle/src/server/withInitialProps.ts @@ -14,11 +14,11 @@ 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 { ComponentProps } from "react"; import urlJoin from "url-join"; +import { getAPIKeyInjectionConfigNode } from "./auth/getApiKeyInjectionConfig"; import type { AuthProps } from "./authProps"; import { getSeoDisabled } from "./disabledSeo"; import { getCustomerAnalytics } from "./getCustomerAnalytics"; 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..cc84a4dd90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1820,6 +1820,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 @@ -17012,10 +17015,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 +17079,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 +17213,7 @@ 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 @@ -17249,6 +17252,52 @@ snapshots: 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 + '@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-sdk/client-sso-oidc' + - aws-crt + '@aws-sdk/core@3.572.0': dependencies: '@smithy/core': 1.4.2 @@ -17295,6 +17344,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 +17397,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 +17471,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 From ce00bf6dad55dd9cd1541b6e7064d8b8892213c1 Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Thu, 3 Oct 2024 18:24:16 -0400 Subject: [PATCH 02/27] update protections on other api routes --- .../[api]/endpoint/[endpoint].ts | 4 +++- .../api-definition/[api]/webhook/[webhook].ts | 4 +++- .../[api]/websocket/[websocket].ts | 4 +++- .../src/pages/api/fern-docs/changelog.ts | 4 +++- .../src/pages/api/fern-docs/resolve-api.ts | 4 +++- .../src/pages/api/fern-docs/search.ts | 8 +++++--- .../src/pages/api/fern-docs/sitemap.xml.ts | 20 +++++++++++++------ .../src/server/auth/checkViewerAllowed.ts | 12 +++++------ 8 files changed, 39 insertions(+), 21 deletions(-) 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 a89044fdb5..b708f44e37 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,5 +1,6 @@ import { ApiDefinitionLoader } from "@/server/ApiDefinitionLoader"; import { checkViewerAllowedNode } from "@/server/auth/checkViewerAllowed"; +import { getAuthEdgeConfig } from "@/server/auth/getAuthEdgeConfig"; import { getXFernHostNode } from "@/server/xfernhost/node"; import * as ApiDefinition from "@fern-api/fdr-sdk/api-definition"; import { NextApiHandler, NextApiResponse } from "next"; @@ -13,7 +14,8 @@ const resolveApiHandler: NextApiHandler = async (req, res: NextApiResponse= 400) { res.status(status).end(); return; 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 57fd15fb5a..14d69445c7 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,5 +1,6 @@ import { ApiDefinitionLoader } from "@/server/ApiDefinitionLoader"; import { checkViewerAllowedNode } from "@/server/auth/checkViewerAllowed"; +import { getAuthEdgeConfig } from "@/server/auth/getAuthEdgeConfig"; import { getXFernHostNode } from "@/server/xfernhost/node"; import * as ApiDefinition from "@fern-api/fdr-sdk/api-definition"; import { NextApiHandler, NextApiResponse } from "next"; @@ -13,7 +14,8 @@ const resolveApiHandler: NextApiHandler = async (req, res: NextApiResponse= 400) { res.status(status).end(); return; 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 416ff59c9b..aae5e936df 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,5 +1,6 @@ import { ApiDefinitionLoader } from "@/server/ApiDefinitionLoader"; import { checkViewerAllowedNode } from "@/server/auth/checkViewerAllowed"; +import { getAuthEdgeConfig } from "@/server/auth/getAuthEdgeConfig"; import { getXFernHostNode } from "@/server/xfernhost/node"; import * as ApiDefinition from "@fern-api/fdr-sdk/api-definition"; import { NextApiHandler, NextApiResponse } from "next"; @@ -13,7 +14,8 @@ const resolveApiHandler: NextApiHandler = async (req, res: NextApiResponse= 400) { res.status(status).end(); return; 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 5150648bf0..707d9e0a15 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,4 +1,5 @@ import { checkViewerAllowedNode } from "@/server/auth/checkViewerAllowed"; +import { getAuthEdgeConfig } from "@/server/auth/getAuthEdgeConfig"; import { buildUrlFromApiNode } from "@/server/buildUrlFromApi"; import { loadWithUrl } from "@/server/loadWithUrl"; import { getXFernHostNode } from "@/server/xfernhost/node"; @@ -26,8 +27,9 @@ export default async function responseApiHandler(req: NextApiRequest, res: NextA } const xFernHost = getXFernHostNode(req); + const auth = await getAuthEdgeConfig(xFernHost); - const status = await checkViewerAllowedNode(xFernHost, req); + const status = await checkViewerAllowedNode(auth, req); if (status >= 400) { return res.status(status).end(); } 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 d1ede64d98..dcaf87b44b 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 @@ -1,5 +1,6 @@ import { DocsKVCache } from "@/server/DocsCache"; import { checkViewerAllowedNode } from "@/server/auth/checkViewerAllowed"; +import { getAuthEdgeConfig } from "@/server/auth/getAuthEdgeConfig"; import { buildUrlFromApiNode } from "@/server/buildUrlFromApi"; import { getXFernHostNode } from "@/server/xfernhost/node"; import { FdrAPI } from "@fern-api/fdr-sdk"; @@ -26,8 +27,9 @@ const resolveApiHandler: NextApiHandler = async ( } const xFernHost = getXFernHostNode(req); + const auth = await getAuthEdgeConfig(xFernHost); - const status = await checkViewerAllowedNode(xFernHost, req); + const status = await checkViewerAllowedNode(auth, req); if (status >= 400) { res.status(status).json(null); return; 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 fda8604c30..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 * 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 { return new NextResponse(null, { status: 405 }); } const xFernHost = getXFernHostEdge(req); + const auth = await getAuthEdgeConfig(xFernHost); - const status = await checkViewerAllowedEdge(xFernHost, req); + const status = await checkViewerAllowedEdge(auth, req); if (status >= 400) { return NextResponse.next({ status }); } - const headers = new Headers(); - headers.set("x-fern-host", xFernHost); - const url = buildUrlFromApiEdge(xFernHost, req); const docs = await loadWithUrl(url); @@ -34,10 +35,17 @@ export default async function GET(req: NextRequest): Promise { const node = FernNavigation.utils.toRootNode(docs.body); const collector = NodeCollector.collect(node); - const urls = collector.indexablePageSlugs.map((slug) => urljoin(xFernHost, slug)); + let slugs = collector.indexablePageSlugs; - const sitemap = getSitemapXml(urls.map((url) => conformTrailingSlash(`https://${url}`))); + // If the domain is basic_token_verification, we only want to include slugs that are allowed + if (auth?.type === "basic_token_verification") { + slugs = slugs.filter((slug) => withBasicTokenViewAllowed(auth.allowlist, `/${slug}`)); + } + const urls = slugs.map((slug) => conformTrailingSlash(urljoin(withDefaultProtocol(xFernHost), slug))); + 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/auth/checkViewerAllowed.ts b/packages/ui/docs-bundle/src/server/auth/checkViewerAllowed.ts index 24e8614c90..dbc38ae8cb 100644 --- a/packages/ui/docs-bundle/src/server/auth/checkViewerAllowed.ts +++ b/packages/ui/docs-bundle/src/server/auth/checkViewerAllowed.ts @@ -1,12 +1,11 @@ +import { AuthEdgeConfig } from "@fern-ui/ui/auth"; import { captureException } from "@sentry/nextjs"; import type { NextApiRequest } from "next"; import type { NextRequest } from "next/server"; import { withBasicTokenViewAllowed } from "../withBasicTokenViewAllowed"; import { verifyFernJWT } from "./FernJWT"; -import { getAuthEdgeConfig } from "./getAuthEdgeConfig"; -export async function checkViewerAllowedEdge(domain: string, req: NextRequest): Promise { - const auth = await getAuthEdgeConfig(domain); +export async function checkViewerAllowedEdge(auth: AuthEdgeConfig | undefined, req: NextRequest): Promise { const fern_token = req.cookies.get("fern_token")?.value; if (auth?.type === "basic_token_verification") { @@ -17,7 +16,7 @@ export async function checkViewerAllowedEdge(domain: string, req: NextRequest): if (fern_token == null) { return 401; } else { - const verified = verifyFernJWT(fern_token, auth.secret, auth.issuer); + const verified = await verifyFernJWT(fern_token, auth.secret, auth.issuer); if (!verified) { return 403; } @@ -26,8 +25,7 @@ export async function checkViewerAllowedEdge(domain: string, req: NextRequest): return 200; } -export async function checkViewerAllowedNode(domain: string, req: NextApiRequest): Promise { - const auth = await getAuthEdgeConfig(domain); +export async function checkViewerAllowedNode(auth: AuthEdgeConfig | undefined, req: NextApiRequest): Promise { const fern_token = req.cookies.fern_token; if (auth?.type === "basic_token_verification") { @@ -43,7 +41,7 @@ export async function checkViewerAllowedNode(domain: string, req: NextApiRequest if (fern_token == null) { return 401; } else { - const verified = verifyFernJWT(fern_token, auth.secret, auth.issuer); + const verified = await verifyFernJWT(fern_token, auth.secret, auth.issuer); if (!verified) { return 403; } From 183349308b2bbfc8ececc011616ba127bce2b022 Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Thu, 3 Oct 2024 21:10:28 -0400 Subject: [PATCH 03/27] prune the tree --- .../src/navigation/utils/followRedirect.ts | 56 +++++++ .../src/navigation/utils/hasChildren.ts | 43 ++++++ .../fdr-sdk/src/navigation/utils/index.ts | 1 + .../navigation/utils/pruneNavigationTree.ts | 144 ++++++++++++++++++ .../src/navigation/utils/updatePointsTo.ts | 13 ++ .../versions/latest/NavigationNodeLeaf.ts | 5 +- .../latest/NavigationNodeWithChildren.ts | 4 + .../latest/NavigationNodeWithRedirect.ts | 44 +++++- .../src/navigation/versions/latest/index.ts | 3 +- .../versions/latest/traverseNavigation.ts | 132 +++++++++++++++- .../versions/v1/traverseNavigation.ts | 10 +- .../ui/app/src/playground/utils/breadcrumb.ts | 2 +- .../src/server/withInitialProps.ts | 24 ++- 13 files changed, 467 insertions(+), 14 deletions(-) create mode 100644 packages/fdr-sdk/src/navigation/utils/followRedirect.ts create mode 100644 packages/fdr-sdk/src/navigation/utils/hasChildren.ts create mode 100644 packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts create mode 100644 packages/fdr-sdk/src/navigation/utils/updatePointsTo.ts create mode 100644 packages/fdr-sdk/src/navigation/versions/latest/NavigationNodeWithChildren.ts 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..bd0002fd29 --- /dev/null +++ b/packages/fdr-sdk/src/navigation/utils/followRedirect.ts @@ -0,0 +1,56 @@ +import visitDiscriminatedUnion from "@fern-ui/core-utils/visitDiscriminatedUnion"; +import { FernNavigation } from "../../../dist"; + +export function followRedirect( + nodeToFollow: FernNavigation.NavigationNode | undefined, +): FernNavigation.Slug | undefined { + if (nodeToFollow == null) { + return undefined; + } + return visitDiscriminatedUnion(nodeToFollow)._visit({ + link: () => undefined, + + // leaf nodes + page: (node) => node.slug, + changelog: (node) => node.slug, + changelogYear: (node) => node.slug, + changelogMonth: (node) => node.slug, + changelogEntry: (node) => node.slug, + endpoint: (node) => node.slug, + webSocket: (node) => node.slug, + webhook: (node) => node.slug, + landingPage: (node) => node.slug, + + // nodes with overview + apiPackage: (node) => (node.overviewPageId != null ? node.slug : followRedirects(node.children)), + section: (node) => (node.overviewPageId != null ? node.slug : followRedirects(node.children)), + apiReference: (node) => (node.overviewPageId != null ? node.slug : followRedirects(node.children)), + + // version is a special case where it should only consider it's first child (the first version) + product: (node) => followRedirect(node.child), + productgroup: (node) => followRedirect(node.children.filter((node) => !node.hidden)[0]), + versioned: (node) => followRedirect(node.children.filter((node) => !node.hidden)[0]), + unversioned: (node) => followRedirect(node.landingPage ?? node.child), + tabbed: (node) => followRedirects(node.children), + sidebarRoot: (node) => followRedirects(node.children), + endpointPair: (node) => followRedirect(node.nonStream), + root: (node) => followRedirect(node.child), + version: (node) => followRedirect(node.child), + tab: (node) => followRedirect(node.child), + sidebarGroup: (node) => followRedirects(node.children), + }); +} + +export function followRedirects(nodes: 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; +} 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..d525d6a0d7 --- /dev/null +++ b/packages/fdr-sdk/src/navigation/utils/hasChildren.ts @@ -0,0 +1,43 @@ +import { UnreachableCaseError } from "ts-essentials"; +import { NavigationNodeWithChildren } from "../versions"; + +export function hasChildren(node: NavigationNodeWithChildren): 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 c728a029ce..cefef46199 100644 --- a/packages/fdr-sdk/src/navigation/utils/index.ts +++ b/packages/fdr-sdk/src/navigation/utils/index.ts @@ -4,4 +4,5 @@ export * from "./createBreadcrumbs"; export * from "./findNode"; export * from "./getApiReferenceId"; export * from "./getNoIndexFromFrontmatter"; +export * from "./pruneNavigationTree"; export * from "./toRootNode"; 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..6bc95e33ed --- /dev/null +++ b/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts @@ -0,0 +1,144 @@ +import { UnreachableCaseError } from "ts-essentials"; +import { FernNavigation } from "../.."; +import { hasChildren } from "./hasChildren"; +import { updatePointsTo } 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 + */ +export function pruneNavigationTree( + root: ROOT, + keep: (node: FernNavigation.NavigationNode) => boolean, +): ROOT | undefined { + const clone = structuredClone(root); + + // keeps track of deleted nodes to avoid deleting them multiple times + const deleted = new Set(); + + FernNavigation.traverseNavigationLevelOrder(clone, (node, parents) => { + // if the node was already deleted, we don't need to traverse it + if (deleted.has(node.id)) { + return "skip"; + } + + // continue traversal if the node is not to be deleted + if (keep(node)) { + return; + } + + deleteChild(node, parents, deleted).forEach((id) => { + deleted.add(id); + }); + + // since the node was deleted, its children are deleted too + // we don't need to traverse them, nor do we need to keep them in the tree. + return "skip"; + }); + + if (deleted.has(clone.id)) { + return undefined; + } + + if (deleted.size > 0) { + // since the tree has been pruned, we need to update the pointsTo property + updatePointsTo(clone); + } + + return clone; +} + +/** + * Deletes a child from a parent node + * + * If the parent node cannot be deleted, it will deleted too via recursion. + * + * @param node the child node to delete + * @param parent the parent node + * @param deleted a set of nodes that have already been deleted + * @returns a list of deleted nodes + */ +function deleteChild( + node: FernNavigation.NavigationNode, + parents: readonly FernNavigation.NavigationNodeWithChildren[], + deleted: Set = new Set(), +): FernNavigation.NodeId[] { + const ancestors = [...parents]; + const parent = ancestors.pop(); // the parent node is the last element in the array + if (parent == null) { + return []; + } else if (deleted.has(parent.id)) { + return [node.id]; + } + + const internalDeleted = (() => { + switch (parent.type) { + case "apiPackage": + parent.children = parent.children.filter((child) => child.id !== node.id); + return [node.id]; + case "apiReference": + parent.children = parent.children.filter((child) => child.id !== node.id); + parent.changelog = parent.changelog?.id === node.id ? undefined : parent.changelog; + return [node.id]; + case "changelog": + parent.children = parent.children.filter((child) => child.id !== node.id); + return [node.id]; + case "changelogYear": + parent.children = parent.children.filter((child) => child.id !== node.id); + return [node.id]; + case "changelogMonth": + parent.children = parent.children.filter((child) => child.id !== node.id); + return [node.id]; + case "endpointPair": + return [...deleteChild(parent, ancestors), node.id]; + case "productgroup": + parent.children = parent.children.filter((child) => child.id !== node.id); + parent.landingPage = parent.landingPage?.id === node.id ? undefined : parent.landingPage; + return [node.id]; + case "product": + return [...deleteChild(parent, ancestors), node.id]; + case "root": + return [...deleteChild(parent, ancestors), node.id]; + case "unversioned": + if (node.id === parent.landingPage?.id) { + parent.landingPage = undefined; + return [node.id]; + } + return [...deleteChild(parent, ancestors), node.id]; + case "section": + parent.children = parent.children.filter((child) => child.id !== node.id); + return [node.id]; + case "sidebarGroup": + parent.children = parent.children.filter((child) => child.id !== node.id); + return [node.id]; + case "tab": + return [...deleteChild(parent, ancestors), node.id]; + case "sidebarRoot": + parent.children = parent.children.filter((child) => child.id !== node.id); + return [node.id]; + case "tabbed": + parent.children = parent.children.filter((child) => child.id !== node.id); + return [node.id]; + case "version": + if (node.id === parent.landingPage?.id) { + parent.landingPage = undefined; + return [node.id]; + } + return [...deleteChild(parent, ancestors), node.id]; + case "versioned": + parent.children = parent.children.filter((child) => child.id !== node.id); + return [node.id]; + default: + throw new UnreachableCaseError(parent); + } + })(); + + // after deletion, if the node has no children, we can delete the parent node too + if (!hasChildren(parent) && !internalDeleted.includes(parent.id)) { + return [...deleteChild(parent, ancestors), ...internalDeleted]; + } + + return internalDeleted; +} 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..e2788db155 --- /dev/null +++ b/packages/fdr-sdk/src/navigation/utils/updatePointsTo.ts @@ -0,0 +1,13 @@ +import { NavigationNode, hasPointsTo, traverseNavigationLevelOrder } from "../versions/latest"; +import { followRedirect } from "./followRedirect"; + +/** + * @param input will be mutated + */ +export function updatePointsTo(input: NavigationNode): void { + traverseNavigationLevelOrder(input, (node) => { + if (hasPointsTo(node)) { + node.pointsTo = followRedirect(node); + } + }); +} 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/NavigationNodeWithChildren.ts b/packages/fdr-sdk/src/navigation/versions/latest/NavigationNodeWithChildren.ts new file mode 100644 index 0000000000..1c358067e2 --- /dev/null +++ b/packages/fdr-sdk/src/navigation/versions/latest/NavigationNodeWithChildren.ts @@ -0,0 +1,4 @@ +import { NavigationNode } from "./NavigationNode"; +import { NavigationNodeLeaf } from "./NavigationNodeLeaf"; + +export type NavigationNodeWithChildren = 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/index.ts b/packages/fdr-sdk/src/navigation/versions/latest/index.ts index 6ad971b787..3a8db31d85 100644 --- a/packages/fdr-sdk/src/navigation/versions/latest/index.ts +++ b/packages/fdr-sdk/src/navigation/versions/latest/index.ts @@ -1,6 +1,5 @@ export * from "../../../client/generated/api/resources/commons"; export * from "../../../client/generated/api/resources/navigation/resources/latest/types"; -export * from "./getPageId"; export * from "./NavigationNode"; export * from "./NavigationNodeApiLeaf"; export * from "./NavigationNodeLeaf"; @@ -10,8 +9,10 @@ export * from "./NavigationNodePage"; export * from "./NavigationNodePageLeaf"; export * from "./NavigationNodeSection"; export * from "./NavigationNodeSectionOverview"; +export * from "./NavigationNodeWithChildren"; export * from "./NavigationNodeWithMetadata"; export * from "./NavigationNodeWithRedirect"; +export * from "./getPageId"; export * from "./slugjoin"; export * from "./toDefaultSlug"; export * from "./traverseNavigation"; diff --git a/packages/fdr-sdk/src/navigation/versions/latest/traverseNavigation.ts b/packages/fdr-sdk/src/navigation/versions/latest/traverseNavigation.ts index 4a5c965e14..22b5bc2660 100644 --- a/packages/fdr-sdk/src/navigation/versions/latest/traverseNavigation.ts +++ b/packages/fdr-sdk/src/navigation/versions/latest/traverseNavigation.ts @@ -1,16 +1,24 @@ import visitDiscriminatedUnion from "@fern-ui/core-utils/visitDiscriminatedUnion"; import { noop } from "ts-essentials"; import { NavigationNode } from "./NavigationNode"; +import { isLeaf } from "./NavigationNodeLeaf"; +import { NavigationNodeWithChildren } from "./NavigationNodeWithChildren"; const SKIP = "skip" as const; -// const CONTINUE = true as const; const STOP = false as const; +/** + * Traverse the navigation tree in a depth-first manner (pre-order). + */ export function traverseNavigation( node: NavigationNode, - visit: (node: NavigationNode, index: number | undefined, parents: NavigationNode[]) => boolean | typeof SKIP | void, + visit: ( + node: NavigationNode, + index: number | undefined, + parents: NavigationNodeWithChildren[], + ) => boolean | typeof SKIP | void, ): void { - function internalChildrenTraverser(nodes: NavigationNode[], parents: NavigationNode[]): boolean | void { + function internalChildrenTraverser(nodes: NavigationNode[], parents: NavigationNodeWithChildren[]): boolean | void { for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if (node == null) { @@ -26,7 +34,7 @@ export function traverseNavigation( function internalTraverser( node: NavigationNode, index: number | undefined, - parents: NavigationNode[], + parents: NavigationNodeWithChildren[], ): boolean | void { const v = visit(node, index, parents); if (v === SKIP) { @@ -37,7 +45,15 @@ export function traverseNavigation( 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]), + productgroup: (productgroup) => { + if (productgroup.landingPage != null) { + const result = internalTraverser(productgroup.landingPage, undefined, [...parents, productgroup]); + if (result === STOP) { + return STOP; + } + } + return internalChildrenTraverser(productgroup.children, [...parents, productgroup]); + }, versioned: (versioned) => internalChildrenTraverser(versioned.children, [...parents, versioned]), tabbed: (tabbed) => internalChildrenTraverser(tabbed.children, [...parents, tabbed]), sidebarRoot: (sidebar) => internalChildrenTraverser(sidebar.children, [...parents, sidebar]), @@ -97,3 +113,109 @@ export function traverseNavigation( } internalTraverser(node, undefined, []); } + +export function traverseNavigationLevelOrder( + node: NavigationNode, + visit: (node: NavigationNode, parent: NavigationNodeWithChildren[]) => typeof SKIP | void, +) { + const queue: [NavigationNode, NavigationNodeWithChildren[]][] = [[node, []]]; + while (queue.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const [node, parents] = queue.shift()!; + + const result = visit(node, parents); + if (result === SKIP) { + continue; + } + + if (isLeaf(node)) { + continue; + } + + visitDiscriminatedUnion(node)._visit({ + root: (root) => queue.push([root.child, [...parents, root]]), + product: (product) => queue.push([product.child, [...parents, product]]), + productgroup: (productgroup) => { + if (productgroup.landingPage) { + queue.push([productgroup.landingPage, [...parents, productgroup]]); + } + + for (const child of productgroup.children) { + queue.push([child, [...parents, productgroup]]); + } + }, + versioned: (versioned) => { + for (const child of versioned.children) { + queue.push([child, [...parents, versioned]]); + } + }, + tabbed: (tabbed) => { + for (const child of tabbed.children) { + queue.push([child, [...parents, tabbed]]); + } + }, + sidebarRoot: (sidebar) => { + for (const child of sidebar.children) { + queue.push([child, [...parents, sidebar]]); + } + }, + sidebarGroup: (sidebarGroup) => { + for (const child of sidebarGroup.children) { + queue.push([child, [...parents, sidebarGroup]]); + } + }, + version: (version) => { + if (version.landingPage != null) { + queue.push([version.landingPage, [...parents, version]]); + } + queue.push([version.child, [...parents, version]]); + }, + tab: (tab) => { + queue.push([tab.child, [...parents, tab]]); + }, + section: (section) => { + for (const child of section.children) { + queue.push([child, [...parents, section]]); + } + }, + apiReference: (apiReference) => { + for (const child of apiReference.children) { + queue.push([child, [...parents, apiReference]]); + } + if (apiReference.changelog != null) { + queue.push([apiReference.changelog, [...parents, apiReference]]); + } + }, + changelog: (changelog) => { + for (const child of changelog.children) { + queue.push([child, [...parents, changelog]]); + } + }, + changelogYear: (changelogYear) => { + for (const child of changelogYear.children) { + queue.push([child, [...parents, changelogYear]]); + } + }, + changelogMonth: (changelogMonth) => { + for (const child of changelogMonth.children) { + queue.push([child, [...parents, changelogMonth]]); + } + }, + apiPackage: (apiPackage) => { + for (const child of apiPackage.children) { + queue.push([child, [...parents, apiPackage]]); + } + }, + endpointPair: (endpointPair) => { + queue.push([endpointPair.nonStream, [...parents, endpointPair]]); + queue.push([endpointPair.stream, [...parents, endpointPair]]); + }, + unversioned: (unversioned) => { + if (unversioned.landingPage != null) { + queue.push([unversioned.landingPage, [...parents, unversioned]]); + } + queue.push([unversioned.child, [...parents, unversioned]]); + }, + }); + } +} diff --git a/packages/fdr-sdk/src/navigation/versions/v1/traverseNavigation.ts b/packages/fdr-sdk/src/navigation/versions/v1/traverseNavigation.ts index 4a5c965e14..53994bcf8e 100644 --- a/packages/fdr-sdk/src/navigation/versions/v1/traverseNavigation.ts +++ b/packages/fdr-sdk/src/navigation/versions/v1/traverseNavigation.ts @@ -37,7 +37,15 @@ export function traverseNavigation( 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]), + productgroup: (productgroup) => { + if (productgroup.landingPage != null) { + const result = internalTraverser(productgroup.landingPage, undefined, [...parents, productgroup]); + if (result === STOP) { + return STOP; + } + } + return internalChildrenTraverser(productgroup.children, [...parents, productgroup]); + }, versioned: (versioned) => internalChildrenTraverser(versioned.children, [...parents, versioned]), tabbed: (tabbed) => internalChildrenTraverser(tabbed.children, [...parents, tabbed]), sidebarRoot: (sidebar) => internalChildrenTraverser(sidebar.children, [...parents, sidebar]), 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/docs-bundle/src/server/withInitialProps.ts b/packages/ui/docs-bundle/src/server/withInitialProps.ts index 4bc7666083..ba92b20ea7 100644 --- a/packages/ui/docs-bundle/src/server/withInitialProps.ts +++ b/packages/ui/docs-bundle/src/server/withInitialProps.ts @@ -19,12 +19,14 @@ import { GetServerSidePropsResult } from "next"; 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 { withBasicTokenViewAllowed } from "./withBasicTokenViewAllowed"; interface WithInitialProps { docs: LoadWithUrlResponse; @@ -56,12 +58,32 @@ export async function withInitialProps({ } const featureFlags = await getFeatureFlags(xFernHost); - const root = FernNavigation.utils.toRootNode( + let root: FernNavigation.RootNode | undefined = FernNavigation.utils.toRootNode( docs, featureFlags.isBatchStreamToggleDisabled, featureFlags.isApiScrollingDisabled, ); + 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 != null && authConfig.type === "basic_token_verification" && auth == null) { + root = FernNavigation.utils.pruneNavigationTree(root, (node) => { + if (FernNavigation.isPage(node)) { + return withBasicTokenViewAllowed(authConfig.allowlist, `/${node.slug}`); + } + + return true; + }); + } + + // 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) { return { From d72b840adf840dbaaa2af3b8716c98b2da0cfceb Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Thu, 3 Oct 2024 21:22:25 -0400 Subject: [PATCH 04/27] fix --- packages/fdr-sdk/src/navigation/utils/followRedirect.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fdr-sdk/src/navigation/utils/followRedirect.ts b/packages/fdr-sdk/src/navigation/utils/followRedirect.ts index bd0002fd29..6c260ee107 100644 --- a/packages/fdr-sdk/src/navigation/utils/followRedirect.ts +++ b/packages/fdr-sdk/src/navigation/utils/followRedirect.ts @@ -1,5 +1,5 @@ import visitDiscriminatedUnion from "@fern-ui/core-utils/visitDiscriminatedUnion"; -import { FernNavigation } from "../../../dist"; +import { FernNavigation } from "../.."; export function followRedirect( nodeToFollow: FernNavigation.NavigationNode | undefined, From ea4c20d72c762c8ae5fc65e6c72b61843ce5a086 Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Thu, 3 Oct 2024 21:29:14 -0400 Subject: [PATCH 05/27] depcheck --- packages/ui/app/package.json | 1 - pnpm-lock.yaml | 67 +++++++++++++++++++++--------------- 2 files changed, 39 insertions(+), 29 deletions(-) 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/pnpm-lock.yaml b/pnpm-lock.yaml index cc84a4dd90..e0e13ee1ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -446,7 +446,7 @@ importers: version: 3.3.2 simple-git: specifier: ^3.24.0 - version: 3.24.0(supports-color@8.1.1) + version: 3.24.0 stylelint: specifier: ^16.1.0 version: 16.5.0(typescript@5.4.3) @@ -1127,9 +1127,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 @@ -2660,7 +2657,7 @@ importers: version: 3.21.0(serverless@3.38.0) simple-git: specifier: ^3.24.0 - version: 3.24.0(supports-color@8.1.1) + version: 3.24.0 tmp-promise: specifier: ^3.0.3 version: 3.0.3 @@ -17684,7 +17681,7 @@ snapshots: '@babel/traverse': 7.24.5 '@babel/types': 7.24.5 convert-source-map: 2.0.0 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4(supports-color@5.5.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -18483,7 +18480,7 @@ snapshots: '@babel/helper-split-export-declaration': 7.24.5 '@babel/parser': 7.24.5 '@babel/types': 7.24.5 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4(supports-color@5.5.0) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -18498,7 +18495,7 @@ snapshots: '@babel/helper-split-export-declaration': 7.24.5 '@babel/parser': 7.24.5 '@babel/types': 7.24.5 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4(supports-color@5.5.0) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -18722,7 +18719,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.12.6 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4(supports-color@5.5.0) espree: 9.6.1 globals: 13.24.0 ignore: 5.3.1 @@ -18985,7 +18982,7 @@ snapshots: '@humanwhocodes/config-array@0.11.14': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4(supports-color@5.5.0) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -19453,6 +19450,12 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.4.15 + '@kwsites/file-exists@1.1.1': + dependencies: + debug: 4.3.4(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + '@kwsites/file-exists@1.1.1(supports-color@8.1.1)': dependencies: debug: 4.3.4(supports-color@8.1.1) @@ -24168,7 +24171,7 @@ snapshots: '@typescript-eslint/type-utils': 7.3.1(eslint@8.57.0)(typescript@5.4.3) '@typescript-eslint/utils': 7.3.1(eslint@8.57.0)(typescript@5.4.3) '@typescript-eslint/visitor-keys': 7.3.1 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4(supports-color@5.5.0) eslint: 8.57.0 graphemer: 1.4.0 ignore: 5.3.1 @@ -24186,7 +24189,7 @@ snapshots: '@typescript-eslint/types': 7.17.0 '@typescript-eslint/typescript-estree': 7.17.0(typescript@5.4.3) '@typescript-eslint/visitor-keys': 7.17.0 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4(supports-color@5.5.0) eslint: 8.57.0 optionalDependencies: typescript: 5.4.3 @@ -24199,7 +24202,7 @@ snapshots: '@typescript-eslint/types': 7.2.0 '@typescript-eslint/typescript-estree': 7.2.0(typescript@5.4.3) '@typescript-eslint/visitor-keys': 7.2.0 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4(supports-color@5.5.0) eslint: 8.57.0 optionalDependencies: typescript: 5.4.3 @@ -24212,7 +24215,7 @@ snapshots: '@typescript-eslint/types': 7.3.1 '@typescript-eslint/typescript-estree': 7.3.1(typescript@5.4.3) '@typescript-eslint/visitor-keys': 7.3.1 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4(supports-color@5.5.0) eslint: 8.57.0 optionalDependencies: typescript: 5.4.3 @@ -24265,7 +24268,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 7.3.1(typescript@5.4.3) '@typescript-eslint/utils': 7.3.1(eslint@8.57.0)(typescript@5.4.3) - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4(supports-color@5.5.0) eslint: 8.57.0 ts-api-utils: 1.3.0(typescript@5.4.3) optionalDependencies: @@ -24348,7 +24351,7 @@ snapshots: dependencies: '@typescript-eslint/types': 7.3.1 '@typescript-eslint/visitor-keys': 7.3.1 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4(supports-color@5.5.0) globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.3 @@ -27432,7 +27435,7 @@ snapshots: callsite: 1.0.0 camelcase: 6.3.0 cosmiconfig: 7.1.0 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4(supports-color@5.5.0) deps-regex: 0.2.0 findup-sync: 5.0.0 ignore: 5.3.1 @@ -27922,7 +27925,7 @@ snapshots: eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0): dependencies: - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4(supports-color@5.5.0) enhanced-resolve: 5.16.1 eslint: 8.57.0 eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0) @@ -28136,7 +28139,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.3 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4(supports-color@5.5.0) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -29568,7 +29571,7 @@ snapshots: dependencies: '@ioredis/commands': 1.2.0 cluster-key-slot: 1.1.2 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4(supports-color@5.5.0) denque: 2.1.0 lodash.defaults: 4.2.0 lodash.isarguments: 3.1.0 @@ -30531,7 +30534,7 @@ snapshots: dependencies: chalk: 5.3.0 commander: 11.0.0 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4(supports-color@5.5.0) execa: 7.2.0 lilconfig: 2.1.0 listr2: 6.6.1 @@ -33969,6 +33972,14 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 + simple-git@3.24.0: + dependencies: + '@kwsites/file-exists': 1.1.1 + '@kwsites/promise-deferred': 1.1.1 + debug: 4.3.4(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + simple-git@3.24.0(supports-color@8.1.1): dependencies: '@kwsites/file-exists': 1.1.1(supports-color@8.1.1) @@ -34409,7 +34420,7 @@ snapshots: cosmiconfig: 9.0.0(typescript@5.4.3) css-functions-list: 3.2.2 css-tree: 2.3.1 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4(supports-color@5.5.0) fast-glob: 3.3.2 fastest-levenshtein: 1.0.16 file-entry-cache: 8.0.0 @@ -34447,7 +34458,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.4(supports-color@5.5.0) glob: 7.2.3 sax: 1.3.0 source-map: 0.7.4 @@ -35044,7 +35055,7 @@ snapshots: bundle-require: 4.1.0(esbuild@0.20.2) cac: 6.7.14 chokidar: 3.6.0 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4(supports-color@5.5.0) esbuild: 0.20.2 execa: 5.1.1 globby: 11.1.0 @@ -35566,7 +35577,7 @@ snapshots: vite-node@1.6.0(@types/node@18.19.33)(less@4.2.0)(sass@1.77.0)(stylus@0.62.0)(terser@5.31.0): dependencies: cac: 6.7.14 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4(supports-color@5.5.0) pathe: 1.1.2 picocolors: 1.0.0 vite: 5.2.11(@types/node@18.19.33)(less@4.2.0)(sass@1.77.0)(stylus@0.62.0)(terser@5.31.0) @@ -35583,7 +35594,7 @@ snapshots: vite-node@1.6.0(@types/node@22.5.5)(less@4.2.0)(sass@1.77.0)(stylus@0.62.0)(terser@5.31.0): dependencies: cac: 6.7.14 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4(supports-color@5.5.0) pathe: 1.1.2 picocolors: 1.0.0 vite: 5.2.11(@types/node@22.5.5)(less@4.2.0)(sass@1.77.0)(stylus@0.62.0)(terser@5.31.0) @@ -35707,7 +35718,7 @@ snapshots: '@vitest/utils': 1.6.0 acorn-walk: 8.3.2 chai: 4.4.1 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4(supports-color@5.5.0) execa: 8.0.1 local-pkg: 0.5.0 magic-string: 0.30.10 @@ -35742,7 +35753,7 @@ snapshots: '@vitest/utils': 1.6.0 acorn-walk: 8.3.2 chai: 4.4.1 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4(supports-color@5.5.0) execa: 8.0.1 local-pkg: 0.5.0 magic-string: 0.30.10 From 520edee894bf8eaba5ffceb3856ce84664cbe27d Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Fri, 4 Oct 2024 12:57:48 -0400 Subject: [PATCH 06/27] make functions abstract, polyfill structuredClone, etc --- .../src/getSlugForSearchRecord.ts | 4 +- packages/fdr-sdk/package.json | 1 + packages/fdr-sdk/src/declarations.d.ts | 5 + .../fdr-sdk/src/navigation/NodeCollector.ts | 17 +- .../__test__/pruneNavigationTree.test.ts | 98 ++++++++ .../navigation/utils/collectApiReferences.ts | 2 +- .../src/navigation/utils/collectPageIds.ts | 2 +- .../src/navigation/utils/createBreadcrumbs.ts | 2 +- .../src/navigation/utils/deleteChild.ts | 72 ++++++ .../fdr-sdk/src/navigation/utils/findNode.ts | 8 +- .../src/navigation/utils/followRedirect.ts | 70 +++--- .../src/navigation/utils/hasChildren.ts | 4 +- .../navigation/utils/pruneNavigationTree.ts | 149 +++--------- .../src/navigation/utils/pruneVersionNode.ts | 2 +- .../src/navigation/utils/updatePointsTo.ts | 10 +- ...ithChildren.ts => NavigationNodeParent.ts} | 2 +- .../navigation/versions/latest/getChildren.ts | 37 +++ .../src/navigation/versions/latest/index.ts | 6 +- .../navigation/versions/latest/traverseBF.ts | 14 ++ .../navigation/versions/latest/traverseDF.ts | 12 + .../versions/latest/traverseNavigation.ts | 221 ------------------ .../src/utils/traversers/__test__/bfs.test.ts | 71 ++++++ .../src/utils/traversers/__test__/dfs.test.ts | 69 ++++++ .../src/utils/traversers/__test__/fixture.ts | 42 ++++ .../traversers/__test__/prunetree.test.ts | 95 ++++++++ packages/fdr-sdk/src/utils/traversers/bfs.ts | 27 +++ packages/fdr-sdk/src/utils/traversers/dfs.ts | 26 +++ .../fdr-sdk/src/utils/traversers/prunetree.ts | 121 ++++++++++ .../fdr-sdk/src/utils/traversers/types.ts | 8 + packages/ui/app/src/atoms/sidebar.ts | 4 +- .../ui/app/src/util/resolveDocsContent.ts | 2 +- pnpm-lock.yaml | 80 +++---- 32 files changed, 834 insertions(+), 449 deletions(-) create mode 100644 packages/fdr-sdk/src/declarations.d.ts create mode 100644 packages/fdr-sdk/src/navigation/utils/__test__/pruneNavigationTree.test.ts create mode 100644 packages/fdr-sdk/src/navigation/utils/deleteChild.ts rename packages/fdr-sdk/src/navigation/versions/latest/{NavigationNodeWithChildren.ts => NavigationNodeParent.ts} (56%) create mode 100644 packages/fdr-sdk/src/navigation/versions/latest/getChildren.ts create mode 100644 packages/fdr-sdk/src/navigation/versions/latest/traverseBF.ts create mode 100644 packages/fdr-sdk/src/navigation/versions/latest/traverseDF.ts delete mode 100644 packages/fdr-sdk/src/navigation/versions/latest/traverseNavigation.ts create mode 100644 packages/fdr-sdk/src/utils/traversers/__test__/bfs.test.ts create mode 100644 packages/fdr-sdk/src/utils/traversers/__test__/dfs.test.ts create mode 100644 packages/fdr-sdk/src/utils/traversers/__test__/fixture.ts create mode 100644 packages/fdr-sdk/src/utils/traversers/__test__/prunetree.test.ts create mode 100644 packages/fdr-sdk/src/utils/traversers/bfs.ts create mode 100644 packages/fdr-sdk/src/utils/traversers/dfs.ts create mode 100644 packages/fdr-sdk/src/utils/traversers/prunetree.ts create mode 100644 packages/fdr-sdk/src/utils/traversers/types.ts diff --git a/packages/commons/search-utils/src/getSlugForSearchRecord.ts b/packages/commons/search-utils/src/getSlugForSearchRecord.ts index 21f465157c..54099f28cf 100644 --- a/packages/commons/search-utils/src/getSlugForSearchRecord.ts +++ b/packages/commons/search-utils/src/getSlugForSearchRecord.ts @@ -98,7 +98,7 @@ function createSearchPlaceholder(sidebar: FernNavigation.SidebarRootNode | undef function checkHasGuides(sidebar: FernNavigation.SidebarRootNode): boolean { let hasGuides = false; - FernNavigation.traverseNavigation(sidebar, (node) => { + FernNavigation.traverseBF(sidebar, (node) => { if (node.type === "page") { hasGuides = true; return false; @@ -113,7 +113,7 @@ function checkHasGuides(sidebar: FernNavigation.SidebarRootNode): boolean { function checkHasEndpoints(sidebar: FernNavigation.SidebarRootNode): boolean { let hasEndpoints = false; - FernNavigation.traverseNavigation(sidebar, (node) => { + FernNavigation.traverseBF(sidebar, (node) => { if (node.type === "apiReference") { hasEndpoints = true; return false; diff --git a/packages/fdr-sdk/package.json b/packages/fdr-sdk/package.json index 4f7a9d1ff7..e1c7bae3a4 100644 --- a/packages/fdr-sdk/package.json +++ b/packages/fdr-sdk/package.json @@ -32,6 +32,7 @@ }, "dependencies": { "@fern-ui/core-utils": "workspace:*", + "core-js-pure": "^3.38.1", "dayjs": "^1.11.11", "fast-deep-equal": "^3.1.3", "form-data": "4.0.0", diff --git a/packages/fdr-sdk/src/declarations.d.ts b/packages/fdr-sdk/src/declarations.d.ts new file mode 100644 index 0000000000..6951696c9b --- /dev/null +++ b/packages/fdr-sdk/src/declarations.d.ts @@ -0,0 +1,5 @@ +declare module "core-js-pure/actual/structured-clone" { + const structuredClone: (value: T) => T; + + export default structuredClone; +} diff --git a/packages/fdr-sdk/src/navigation/NodeCollector.ts b/packages/fdr-sdk/src/navigation/NodeCollector.ts index 1c98153904..210dd25b9f 100644 --- a/packages/fdr-sdk/src/navigation/NodeCollector.ts +++ b/packages/fdr-sdk/src/navigation/NodeCollector.ts @@ -1,10 +1,11 @@ +import { EMPTY_ARRAY } from "@fern-ui/core-utils"; import { once } from "../utils"; import { FernNavigation } from "./.."; import { pruneVersionNode } from "./utils/pruneVersionNode"; interface NavigationNodeWithMetadataAndParents { node: FernNavigation.NavigationNodeWithMetadata; - parents: FernNavigation.NavigationNode[]; + parents: readonly FernNavigation.NavigationNodeParent[]; next: FernNavigation.NavigationNodeNeighbor | undefined; prev: FernNavigation.NavigationNodeNeighbor | undefined; } @@ -14,7 +15,7 @@ const NodeCollectorInstances = new WeakMap(); - private idToNodeParents = new Map(); + private idToNodeParents = new Map(); private slugToNode = new Map(); private orphanedNodes: FernNavigation.NavigationNodeWithMetadata[] = []; @@ -36,7 +37,7 @@ export class NodeCollector { #setNode( slug: FernNavigation.Slug, node: FernNavigation.NavigationNodeWithMetadata, - parents: FernNavigation.NavigationNode[], + parents: readonly FernNavigation.NavigationNodeParent[], ) { const toSet = { node, parents, prev: this.#lastNeighboringNode, next: undefined }; this.slugToNode.set(slug, toSet); @@ -56,7 +57,7 @@ export class NodeCollector { if (rootNode == null) { return; } - FernNavigation.traverseNavigation(rootNode, (node, _index, parents) => { + FernNavigation.traverseDF(rootNode, (node, parents) => { // if the node is the default version, make a copy of it and "prune" the version slug from all children nodes if (node.type === "version") { this.versionNodes.push(node); @@ -65,7 +66,7 @@ export class NodeCollector { if (node.type === "version" && node.default && rootNode.type === "root") { const copy = JSON.parse(JSON.stringify(node)) as FernNavigation.VersionNode; this.defaultVersion = pruneVersionNode(copy, rootNode.slug, node.slug); - FernNavigation.traverseNavigation(this.defaultVersion, (node, _index, innerParents) => { + FernNavigation.traverseDF(this.defaultVersion, (node, innerParents) => { this.visitNode(node, [...parents, ...innerParents], true); }); } @@ -76,7 +77,7 @@ export class NodeCollector { private visitNode( node: FernNavigation.NavigationNode, - parents: FernNavigation.NavigationNode[], + parents: readonly FernNavigation.NavigationNodeParent[], isDefaultVersion = false, ): void { if (!this.idToNode.has(node.id) || isDefaultVersion) { @@ -137,8 +138,8 @@ export class NodeCollector { return this.idToNode.get(id); } - public getParents(id: FernNavigation.NodeId): FernNavigation.NavigationNode[] { - return this.idToNodeParents.get(id) ?? []; + public getParents(id: FernNavigation.NodeId): readonly FernNavigation.NavigationNodeParent[] { + return this.idToNodeParents.get(id) ?? EMPTY_ARRAY; } public getSlugMapWithParents = (): ReadonlyMap => { diff --git a/packages/fdr-sdk/src/navigation/utils/__test__/pruneNavigationTree.test.ts b/packages/fdr-sdk/src/navigation/utils/__test__/pruneNavigationTree.test.ts new file mode 100644 index 0000000000..0dfd3494ac --- /dev/null +++ b/packages/fdr-sdk/src/navigation/utils/__test__/pruneNavigationTree.test.ts @@ -0,0 +1,98 @@ +import { FernNavigation } from "../../.."; +import { pruneNavigationTree } from "../pruneNavigationTree"; + +describe("pruneNavigationTree", () => { + it("should not prune the tree if keep returns true for all nodes", () => { + const root: FernNavigation.NavigationNode = { + type: "section", + id: FernNavigation.NodeId("root"), + slug: FernNavigation.Slug("root"), + title: "Root", + children: [ + { + type: "page", + id: FernNavigation.NodeId("page"), + slug: FernNavigation.Slug("root/page"), + title: "Page", + pageId: FernNavigation.PageId("page.mdx"), + canonicalSlug: undefined, + icon: undefined, + hidden: undefined, + noindex: undefined, + }, + ], + collapsed: undefined, + canonicalSlug: undefined, + icon: undefined, + hidden: undefined, + overviewPageId: undefined, + noindex: undefined, + pointsTo: undefined, + }; + + const result = pruneNavigationTree(root, () => true); + + // structuredClone should duplicate the object + expect(result === root).toBe(false); + + expect(result).toStrictEqual({ + type: "section", + id: FernNavigation.NodeId("root"), + slug: FernNavigation.Slug("root"), + title: "Root", + children: [ + { + type: "page", + id: FernNavigation.NodeId("page"), + slug: FernNavigation.Slug("root/page"), + title: "Page", + pageId: FernNavigation.PageId("page.mdx"), + canonicalSlug: undefined, + icon: undefined, + hidden: undefined, + noindex: undefined, + }, + ], + collapsed: undefined, + canonicalSlug: undefined, + icon: undefined, + hidden: undefined, + overviewPageId: undefined, + noindex: undefined, + pointsTo: undefined, + }); + }); + + it("should return undefined if no visitable pages are left", () => { + const root: FernNavigation.NavigationNode = { + type: "section", + id: FernNavigation.NodeId("root"), + slug: FernNavigation.Slug("root"), + title: "Root", + children: [ + { + type: "page", + id: FernNavigation.NodeId("page"), + slug: FernNavigation.Slug("root/page"), + title: "Page", + pageId: FernNavigation.PageId("page.mdx"), + canonicalSlug: undefined, + icon: undefined, + hidden: undefined, + noindex: undefined, + }, + ], + collapsed: undefined, + canonicalSlug: undefined, + icon: undefined, + hidden: undefined, + overviewPageId: undefined, + noindex: undefined, + pointsTo: undefined, + }; + + const result = pruneNavigationTree(root, (node) => node.id !== FernNavigation.NodeId("page")); + + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/fdr-sdk/src/navigation/utils/collectApiReferences.ts b/packages/fdr-sdk/src/navigation/utils/collectApiReferences.ts index 8d08baf9b6..bf9a9eeeca 100644 --- a/packages/fdr-sdk/src/navigation/utils/collectApiReferences.ts +++ b/packages/fdr-sdk/src/navigation/utils/collectApiReferences.ts @@ -2,7 +2,7 @@ import { FernNavigation } from "../.."; export function collectApiReferences(nav: FernNavigation.NavigationNode): FernNavigation.ApiReferenceNode[] { const apiReferences: FernNavigation.ApiReferenceNode[] = []; - FernNavigation.traverseNavigation(nav, (node) => { + FernNavigation.traverseDF(nav, (node) => { if (node.type === "apiReference") { apiReferences.push(node); return "skip"; diff --git a/packages/fdr-sdk/src/navigation/utils/collectPageIds.ts b/packages/fdr-sdk/src/navigation/utils/collectPageIds.ts index 0658664870..a42b4ef87e 100644 --- a/packages/fdr-sdk/src/navigation/utils/collectPageIds.ts +++ b/packages/fdr-sdk/src/navigation/utils/collectPageIds.ts @@ -3,7 +3,7 @@ import { getPageId } from "../versions/latest/getPageId"; export function collectPageIds(nav: FernNavigation.NavigationNode): Set { const pageIds = new Set(); - FernNavigation.traverseNavigation(nav, (node) => { + FernNavigation.traverseDF(nav, (node) => { if (FernNavigation.isPage(node)) { const pageId = getPageId(node); if (pageId != null) { diff --git a/packages/fdr-sdk/src/navigation/utils/createBreadcrumbs.ts b/packages/fdr-sdk/src/navigation/utils/createBreadcrumbs.ts index 1f2c9b6577..a318411e40 100644 --- a/packages/fdr-sdk/src/navigation/utils/createBreadcrumbs.ts +++ b/packages/fdr-sdk/src/navigation/utils/createBreadcrumbs.ts @@ -2,7 +2,7 @@ import visitDiscriminatedUnion from "@fern-ui/core-utils/visitDiscriminatedUnion import { noop } from "ts-essentials"; import { FernNavigation } from "../.."; -export function createBreadcrumbs(nodes: FernNavigation.NavigationNode[]): FernNavigation.BreadcrumbItem[] { +export function createBreadcrumbs(nodes: readonly FernNavigation.NavigationNode[]): FernNavigation.BreadcrumbItem[] { const breadcrumb: FernNavigation.BreadcrumbItem[] = []; nodes.forEach((node) => { if (!FernNavigation.hasMetadata(node) || FernNavigation.isLeaf(node)) { diff --git a/packages/fdr-sdk/src/navigation/utils/deleteChild.ts b/packages/fdr-sdk/src/navigation/utils/deleteChild.ts new file mode 100644 index 0000000000..bd04391243 --- /dev/null +++ b/packages/fdr-sdk/src/navigation/utils/deleteChild.ts @@ -0,0 +1,72 @@ +import { UnreachableCaseError } from "ts-essentials"; +import { FernNavigation } from "../.."; + +/** + * @param parent delete node from this parent (mutable) + * @param node node to delete + * @returns the id of the deleted node or null if the node was not deletable from the parent + */ +export function mutableDeleteChild( + parent: FernNavigation.NavigationNodeParent, + node: FernNavigation.NavigationNode, +): FernNavigation.NodeId | null { + switch (parent.type) { + case "apiPackage": + parent.children = parent.children.filter((child) => child.id !== node.id); + return node.id; + case "apiReference": + parent.children = parent.children.filter((child) => child.id !== node.id); + parent.changelog = parent.changelog?.id === node.id ? undefined : parent.changelog; + return node.id; + case "changelog": + parent.children = parent.children.filter((child) => child.id !== node.id); + return node.id; + case "changelogYear": + parent.children = parent.children.filter((child) => child.id !== node.id); + return node.id; + case "changelogMonth": + parent.children = parent.children.filter((child) => child.id !== node.id); + return node.id; + case "endpointPair": + return null; + case "productgroup": + parent.children = parent.children.filter((child) => child.id !== node.id); + parent.landingPage = parent.landingPage?.id === node.id ? undefined : parent.landingPage; + return node.id; + case "product": + return null; + case "root": + return null; + case "unversioned": + if (node.id === parent.landingPage?.id) { + parent.landingPage = undefined; + return node.id; + } + return null; + case "section": + parent.children = parent.children.filter((child) => child.id !== node.id); + return node.id; + case "sidebarGroup": + parent.children = parent.children.filter((child) => child.id !== node.id); + return node.id; + case "tab": + return null; + case "sidebarRoot": + parent.children = parent.children.filter((child) => child.id !== node.id); + return node.id; + case "tabbed": + parent.children = parent.children.filter((child) => child.id !== node.id); + return node.id; + case "version": + if (node.id === parent.landingPage?.id) { + parent.landingPage = undefined; + return node.id; + } + return null; + case "versioned": + parent.children = parent.children.filter((child) => child.id !== node.id); + return node.id; + default: + throw new UnreachableCaseError(parent); + } +} diff --git a/packages/fdr-sdk/src/navigation/utils/findNode.ts b/packages/fdr-sdk/src/navigation/utils/findNode.ts index 97596d1ce8..f6c1363216 100644 --- a/packages/fdr-sdk/src/navigation/utils/findNode.ts +++ b/packages/fdr-sdk/src/navigation/utils/findNode.ts @@ -13,13 +13,13 @@ export declare namespace Node { interface Found { type: "found"; node: FernNavigation.NavigationNodePage; - parents: FernNavigation.NavigationNode[]; - breadcrumb: FernNavigation.BreadcrumbItem[]; + parents: readonly FernNavigation.NavigationNode[]; + breadcrumb: readonly FernNavigation.BreadcrumbItem[]; root: FernNavigation.RootNode; - versions: FernNavigation.VersionNode[]; + versions: readonly FernNavigation.VersionNode[]; currentVersion: FernNavigation.VersionNode | undefined; currentTab: FernNavigation.TabNode | FernNavigation.ChangelogNode | undefined; - tabs: FernNavigation.TabChild[]; + tabs: readonly FernNavigation.TabChild[]; sidebar: FernNavigation.SidebarRootNode | undefined; apiReference: FernNavigation.ApiReferenceNode | undefined; next: FernNavigation.NavigationNodeNeighbor | undefined; diff --git a/packages/fdr-sdk/src/navigation/utils/followRedirect.ts b/packages/fdr-sdk/src/navigation/utils/followRedirect.ts index 6c260ee107..d29fd5a623 100644 --- a/packages/fdr-sdk/src/navigation/utils/followRedirect.ts +++ b/packages/fdr-sdk/src/navigation/utils/followRedirect.ts @@ -1,4 +1,4 @@ -import visitDiscriminatedUnion from "@fern-ui/core-utils/visitDiscriminatedUnion"; +import { UnreachableCaseError } from "ts-essentials"; import { FernNavigation } from "../.."; export function followRedirect( @@ -7,41 +7,39 @@ export function followRedirect( if (nodeToFollow == null) { return undefined; } - return visitDiscriminatedUnion(nodeToFollow)._visit({ - link: () => undefined, - // leaf nodes - page: (node) => node.slug, - changelog: (node) => node.slug, - changelogYear: (node) => node.slug, - changelogMonth: (node) => node.slug, - changelogEntry: (node) => node.slug, - endpoint: (node) => node.slug, - webSocket: (node) => node.slug, - webhook: (node) => node.slug, - landingPage: (node) => node.slug, - - // nodes with overview - apiPackage: (node) => (node.overviewPageId != null ? node.slug : followRedirects(node.children)), - section: (node) => (node.overviewPageId != null ? node.slug : followRedirects(node.children)), - apiReference: (node) => (node.overviewPageId != null ? node.slug : followRedirects(node.children)), + if (FernNavigation.isPage(nodeToFollow)) { + return nodeToFollow.slug; + } - // version is a special case where it should only consider it's first child (the first version) - product: (node) => followRedirect(node.child), - productgroup: (node) => followRedirect(node.children.filter((node) => !node.hidden)[0]), - versioned: (node) => followRedirect(node.children.filter((node) => !node.hidden)[0]), - unversioned: (node) => followRedirect(node.landingPage ?? node.child), - tabbed: (node) => followRedirects(node.children), - sidebarRoot: (node) => followRedirects(node.children), - endpointPair: (node) => followRedirect(node.nonStream), - root: (node) => followRedirect(node.child), - version: (node) => followRedirect(node.child), - tab: (node) => followRedirect(node.child), - sidebarGroup: (node) => followRedirects(node.children), - }); + switch (nodeToFollow.type) { + case "link": + return undefined; + /** + * Versioned and ProductGroup nodes are special in that they have a default child. + */ + case "productgroup": + case "versioned": + return followRedirect([...nodeToFollow.children].sort(defaultFirst)[0]); + case "apiReference": + case "apiPackage": + case "endpointPair": + case "product": + case "root": + case "section": + case "sidebarRoot": + case "sidebarGroup": + case "tab": + case "tabbed": + case "unversioned": + case "version": + return followRedirects(FernNavigation.getChildren(nodeToFollow)); + default: + throw new UnreachableCaseError(nodeToFollow); + } } -export function followRedirects(nodes: FernNavigation.NavigationNode[]): FernNavigation.Slug | undefined { +export function followRedirects(nodes: readonly FernNavigation.NavigationNode[]): FernNavigation.Slug | undefined { for (const node of nodes) { // skip hidden nodes if (FernNavigation.hasMetadata(node) && node.hidden) { @@ -54,3 +52,11 @@ export function followRedirects(nodes: FernNavigation.NavigationNode[]): FernNav } return; } + +function rank(node: T): number { + return node.default && !node.hidden ? 1 : node.hidden ? -1 : 0; +} + +function defaultFirst(a: T, b: T): number { + return rank(b) - rank(a); +} diff --git a/packages/fdr-sdk/src/navigation/utils/hasChildren.ts b/packages/fdr-sdk/src/navigation/utils/hasChildren.ts index d525d6a0d7..c0d839e8a5 100644 --- a/packages/fdr-sdk/src/navigation/utils/hasChildren.ts +++ b/packages/fdr-sdk/src/navigation/utils/hasChildren.ts @@ -1,7 +1,7 @@ import { UnreachableCaseError } from "ts-essentials"; -import { NavigationNodeWithChildren } from "../versions"; +import { NavigationNodeParent } from "../versions"; -export function hasChildren(node: NavigationNodeWithChildren): boolean { +export function hasChildren(node: NavigationNodeParent): boolean { switch (node.type) { case "apiPackage": return node.children.length > 0; diff --git a/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts b/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts index 6bc95e33ed..4ff3079a41 100644 --- a/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts +++ b/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts @@ -1,144 +1,47 @@ -import { UnreachableCaseError } from "ts-essentials"; +import structuredClone from "core-js-pure/actual/structured-clone"; +import { DeepReadonly } from "ts-essentials"; import { FernNavigation } from "../.."; +import { prunetree } from "../../utils/traversers/prunetree"; +import { mutableDeleteChild } from "./deleteChild"; import { hasChildren } from "./hasChildren"; -import { updatePointsTo } from "./updatePointsTo"; +import { mutableUpdatePointsTo } from "./updatePointsTo"; /** - * * @param root the root node of the navigation tree * @param keep a function that returns true if the node should be kept - * @returns + * @returns a new navigation tree with only the nodes that should be kept */ export function pruneNavigationTree( - root: ROOT, + root: DeepReadonly, keep: (node: FernNavigation.NavigationNode) => boolean, ): ROOT | undefined { - const clone = structuredClone(root); - - // keeps track of deleted nodes to avoid deleting them multiple times - const deleted = new Set(); - - FernNavigation.traverseNavigationLevelOrder(clone, (node, parents) => { - // if the node was already deleted, we don't need to traverse it - if (deleted.has(node.id)) { - return "skip"; - } - - // continue traversal if the node is not to be deleted - if (keep(node)) { - return; - } - - deleteChild(node, parents, deleted).forEach((id) => { - deleted.add(id); - }); + const clone = structuredClone(root) as ROOT; + return mutablePruneNavigationTree(clone, keep); +} - // since the node was deleted, its children are deleted too - // we don't need to traverse them, nor do we need to keep them in the tree. - return "skip"; +function mutablePruneNavigationTree( + root: ROOT, + keep: (node: FernNavigation.NavigationNode) => boolean, +): ROOT | undefined { + const [result, deleted] = prunetree(root, { + predicate: keep, + getChildren: FernNavigation.getChildren, + getPointer: (node) => node.id, + deleter: mutableDeleteChild, + + // after deletion, if the node no longer has any children, we can delete the parent node too + // but only if the parent node is NOT a visitable page + shouldDeleteParent: (parent) => !hasChildren(parent) && !FernNavigation.isPage(parent), }); - if (deleted.has(clone.id)) { + if (result == null) { return undefined; } if (deleted.size > 0) { // since the tree has been pruned, we need to update the pointsTo property - updatePointsTo(clone); - } - - return clone; -} - -/** - * Deletes a child from a parent node - * - * If the parent node cannot be deleted, it will deleted too via recursion. - * - * @param node the child node to delete - * @param parent the parent node - * @param deleted a set of nodes that have already been deleted - * @returns a list of deleted nodes - */ -function deleteChild( - node: FernNavigation.NavigationNode, - parents: readonly FernNavigation.NavigationNodeWithChildren[], - deleted: Set = new Set(), -): FernNavigation.NodeId[] { - const ancestors = [...parents]; - const parent = ancestors.pop(); // the parent node is the last element in the array - if (parent == null) { - return []; - } else if (deleted.has(parent.id)) { - return [node.id]; - } - - const internalDeleted = (() => { - switch (parent.type) { - case "apiPackage": - parent.children = parent.children.filter((child) => child.id !== node.id); - return [node.id]; - case "apiReference": - parent.children = parent.children.filter((child) => child.id !== node.id); - parent.changelog = parent.changelog?.id === node.id ? undefined : parent.changelog; - return [node.id]; - case "changelog": - parent.children = parent.children.filter((child) => child.id !== node.id); - return [node.id]; - case "changelogYear": - parent.children = parent.children.filter((child) => child.id !== node.id); - return [node.id]; - case "changelogMonth": - parent.children = parent.children.filter((child) => child.id !== node.id); - return [node.id]; - case "endpointPair": - return [...deleteChild(parent, ancestors), node.id]; - case "productgroup": - parent.children = parent.children.filter((child) => child.id !== node.id); - parent.landingPage = parent.landingPage?.id === node.id ? undefined : parent.landingPage; - return [node.id]; - case "product": - return [...deleteChild(parent, ancestors), node.id]; - case "root": - return [...deleteChild(parent, ancestors), node.id]; - case "unversioned": - if (node.id === parent.landingPage?.id) { - parent.landingPage = undefined; - return [node.id]; - } - return [...deleteChild(parent, ancestors), node.id]; - case "section": - parent.children = parent.children.filter((child) => child.id !== node.id); - return [node.id]; - case "sidebarGroup": - parent.children = parent.children.filter((child) => child.id !== node.id); - return [node.id]; - case "tab": - return [...deleteChild(parent, ancestors), node.id]; - case "sidebarRoot": - parent.children = parent.children.filter((child) => child.id !== node.id); - return [node.id]; - case "tabbed": - parent.children = parent.children.filter((child) => child.id !== node.id); - return [node.id]; - case "version": - if (node.id === parent.landingPage?.id) { - parent.landingPage = undefined; - return [node.id]; - } - return [...deleteChild(parent, ancestors), node.id]; - case "versioned": - parent.children = parent.children.filter((child) => child.id !== node.id); - return [node.id]; - default: - throw new UnreachableCaseError(parent); - } - })(); - - // after deletion, if the node has no children, we can delete the parent node too - if (!hasChildren(parent) && !internalDeleted.includes(parent.id)) { - return [...deleteChild(parent, ancestors), ...internalDeleted]; + mutableUpdatePointsTo(result); } - return internalDeleted; + return result; } diff --git a/packages/fdr-sdk/src/navigation/utils/pruneVersionNode.ts b/packages/fdr-sdk/src/navigation/utils/pruneVersionNode.ts index 4d4e5910a7..882968c46b 100644 --- a/packages/fdr-sdk/src/navigation/utils/pruneVersionNode.ts +++ b/packages/fdr-sdk/src/navigation/utils/pruneVersionNode.ts @@ -18,7 +18,7 @@ export function pruneVersionNode( if (node == null) { return undefined; } - FernNavigation.traverseNavigation(node, (node) => { + FernNavigation.traverseDF(node, (node) => { if (FernNavigation.hasMetadata(node)) { const newSlug = FernNavigation.toDefaultSlug(node.slug, rootSlug, versionSlug); // children of this node was already pruned diff --git a/packages/fdr-sdk/src/navigation/utils/updatePointsTo.ts b/packages/fdr-sdk/src/navigation/utils/updatePointsTo.ts index e2788db155..3e979adb9a 100644 --- a/packages/fdr-sdk/src/navigation/utils/updatePointsTo.ts +++ b/packages/fdr-sdk/src/navigation/utils/updatePointsTo.ts @@ -1,12 +1,14 @@ -import { NavigationNode, hasPointsTo, traverseNavigationLevelOrder } from "../versions/latest"; +import { FernNavigation } from "../.."; import { followRedirect } from "./followRedirect"; /** + * Uses depth-first traversal to update the pointsTo property of all nodes in the tree. + * * @param input will be mutated */ -export function updatePointsTo(input: NavigationNode): void { - traverseNavigationLevelOrder(input, (node) => { - if (hasPointsTo(node)) { +export function mutableUpdatePointsTo(input: FernNavigation.NavigationNode): void { + FernNavigation.traverseDF(input, (node) => { + if (FernNavigation.hasPointsTo(node)) { node.pointsTo = followRedirect(node); } }); diff --git a/packages/fdr-sdk/src/navigation/versions/latest/NavigationNodeWithChildren.ts b/packages/fdr-sdk/src/navigation/versions/latest/NavigationNodeParent.ts similarity index 56% rename from packages/fdr-sdk/src/navigation/versions/latest/NavigationNodeWithChildren.ts rename to packages/fdr-sdk/src/navigation/versions/latest/NavigationNodeParent.ts index 1c358067e2..8aa1c18b2f 100644 --- a/packages/fdr-sdk/src/navigation/versions/latest/NavigationNodeWithChildren.ts +++ b/packages/fdr-sdk/src/navigation/versions/latest/NavigationNodeParent.ts @@ -1,4 +1,4 @@ import { NavigationNode } from "./NavigationNode"; import { NavigationNodeLeaf } from "./NavigationNodeLeaf"; -export type NavigationNodeWithChildren = Exclude; +export type NavigationNodeParent = Exclude; diff --git a/packages/fdr-sdk/src/navigation/versions/latest/getChildren.ts b/packages/fdr-sdk/src/navigation/versions/latest/getChildren.ts new file mode 100644 index 0000000000..23731be0f0 --- /dev/null +++ b/packages/fdr-sdk/src/navigation/versions/latest/getChildren.ts @@ -0,0 +1,37 @@ +import { UnreachableCaseError } from "ts-essentials"; +import { NavigationNode } from "./NavigationNode"; +import { isLeaf } from "./NavigationNodeLeaf"; + +export function getChildren(node: NavigationNode): readonly NavigationNode[] { + if (isLeaf(node)) { + return []; + } + + switch (node.type) { + case "apiPackage": + case "changelog": + case "changelogMonth": + case "changelogYear": + case "section": + case "sidebarGroup": + case "sidebarRoot": + case "tabbed": + case "versioned": + return node.children; + case "product": + case "root": + case "tab": + return [node.child]; + case "apiReference": + return [...node.children, ...(node.changelog ? [node.changelog] : [])]; + case "endpointPair": + return [node.nonStream, node.stream]; + case "productgroup": + return [...(node.landingPage ? [node.landingPage] : []), ...node.children]; + case "unversioned": + case "version": + return [...(node.landingPage ? [node.landingPage] : []), node.child]; + default: + throw new UnreachableCaseError(node); + } +} diff --git a/packages/fdr-sdk/src/navigation/versions/latest/index.ts b/packages/fdr-sdk/src/navigation/versions/latest/index.ts index 3a8db31d85..5a31808348 100644 --- a/packages/fdr-sdk/src/navigation/versions/latest/index.ts +++ b/packages/fdr-sdk/src/navigation/versions/latest/index.ts @@ -7,12 +7,14 @@ export * from "./NavigationNodeMarkdown"; export * from "./NavigationNodeNeighbor"; export * from "./NavigationNodePage"; export * from "./NavigationNodePageLeaf"; +export * from "./NavigationNodeParent"; export * from "./NavigationNodeSection"; export * from "./NavigationNodeSectionOverview"; -export * from "./NavigationNodeWithChildren"; export * from "./NavigationNodeWithMetadata"; export * from "./NavigationNodeWithRedirect"; +export * from "./getChildren"; export * from "./getPageId"; export * from "./slugjoin"; export * from "./toDefaultSlug"; -export * from "./traverseNavigation"; +export * from "./traverseBF"; +export * from "./traverseDF"; diff --git a/packages/fdr-sdk/src/navigation/versions/latest/traverseBF.ts b/packages/fdr-sdk/src/navigation/versions/latest/traverseBF.ts new file mode 100644 index 0000000000..3c468f03fd --- /dev/null +++ b/packages/fdr-sdk/src/navigation/versions/latest/traverseBF.ts @@ -0,0 +1,14 @@ +import { bfs } from "../../../utils/traversers/bfs"; +import { TraverserVisit } from "../../../utils/traversers/types"; +import { NavigationNode } from "./NavigationNode"; +import { NavigationNodeParent } from "./NavigationNodeParent"; +import { getChildren } from "./getChildren"; + +const SKIP = "skip" as const; + +/** + * Traverse the navigation tree in a depth-first manner (pre-order). + */ +export function traverseBF(node: NavigationNode, visit: TraverserVisit) { + return bfs(node, visit, getChildren); +} diff --git a/packages/fdr-sdk/src/navigation/versions/latest/traverseDF.ts b/packages/fdr-sdk/src/navigation/versions/latest/traverseDF.ts new file mode 100644 index 0000000000..ce1c6ad1f9 --- /dev/null +++ b/packages/fdr-sdk/src/navigation/versions/latest/traverseDF.ts @@ -0,0 +1,12 @@ +import { dfs } from "../../../utils/traversers/dfs"; +import { TraverserVisit } from "../../../utils/traversers/types"; +import { NavigationNode } from "./NavigationNode"; +import { NavigationNodeParent } from "./NavigationNodeParent"; +import { getChildren } from "./getChildren"; + +/** + * Traverse the navigation tree in a depth-first manner (pre-order). + */ +export function traverseDF(node: NavigationNode, visit: TraverserVisit): void { + return dfs(node, visit, getChildren); +} diff --git a/packages/fdr-sdk/src/navigation/versions/latest/traverseNavigation.ts b/packages/fdr-sdk/src/navigation/versions/latest/traverseNavigation.ts deleted file mode 100644 index 22b5bc2660..0000000000 --- a/packages/fdr-sdk/src/navigation/versions/latest/traverseNavigation.ts +++ /dev/null @@ -1,221 +0,0 @@ -import visitDiscriminatedUnion from "@fern-ui/core-utils/visitDiscriminatedUnion"; -import { noop } from "ts-essentials"; -import { NavigationNode } from "./NavigationNode"; -import { isLeaf } from "./NavigationNodeLeaf"; -import { NavigationNodeWithChildren } from "./NavigationNodeWithChildren"; - -const SKIP = "skip" as const; -const STOP = false as const; - -/** - * Traverse the navigation tree in a depth-first manner (pre-order). - */ -export function traverseNavigation( - node: NavigationNode, - visit: ( - node: NavigationNode, - index: number | undefined, - parents: NavigationNodeWithChildren[], - ) => boolean | typeof SKIP | void, -): void { - function internalChildrenTraverser(nodes: NavigationNode[], parents: NavigationNodeWithChildren[]): boolean | void { - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; - if (node == null) { - throw new Error(`Failed to index into nodes. Index: ${i} Length: ${nodes.length}`); - } - const result = internalTraverser(node, i, parents); - if (result === STOP) { - return STOP; - } - } - return; - } - function internalTraverser( - node: NavigationNode, - index: number | undefined, - parents: NavigationNodeWithChildren[], - ): boolean | void { - const v = visit(node, index, parents); - if (v === SKIP) { - return; - } else if (v === STOP) { - return STOP; - } - return visitDiscriminatedUnion(node)._visit({ - root: (root) => internalTraverser(root.child, undefined, [...parents, root]), - product: (product) => internalTraverser(product.child, undefined, [...parents, product]), - productgroup: (productgroup) => { - if (productgroup.landingPage != null) { - const result = internalTraverser(productgroup.landingPage, undefined, [...parents, productgroup]); - if (result === STOP) { - return STOP; - } - } - return internalChildrenTraverser(productgroup.children, [...parents, productgroup]); - }, - versioned: (versioned) => internalChildrenTraverser(versioned.children, [...parents, versioned]), - tabbed: (tabbed) => internalChildrenTraverser(tabbed.children, [...parents, tabbed]), - sidebarRoot: (sidebar) => internalChildrenTraverser(sidebar.children, [...parents, sidebar]), - sidebarGroup: (sidebarGroup) => - internalChildrenTraverser(sidebarGroup.children, [...parents, sidebarGroup]), - version: (version) => { - if (version.landingPage != null) { - const result = internalTraverser(version.landingPage, undefined, [...parents, version]); - if (result === STOP) { - return STOP; - } - } - return internalTraverser(version.child, undefined, [...parents, version]); - }, - tab: (tab) => internalTraverser(tab.child, undefined, [...parents, tab]), - link: noop, - page: noop, - landingPage: noop, - section: (section) => internalChildrenTraverser(section.children, [...parents, section]), - apiReference: (apiReference) => { - const result = internalChildrenTraverser(apiReference.children, [...parents, apiReference]); - if (result === STOP) { - return STOP; - } - if (apiReference.changelog != null) { - return internalTraverser(apiReference.changelog, undefined, [...parents, apiReference]); - } - }, - changelog: (changelog) => internalChildrenTraverser(changelog.children, [...parents, changelog]), - changelogYear: (changelogYear) => - internalChildrenTraverser(changelogYear.children, [...parents, changelogYear]), - changelogMonth: (changelogMonth) => - internalChildrenTraverser(changelogMonth.children, [...parents, changelogMonth]), - changelogEntry: noop, - endpoint: noop, - webSocket: noop, - webhook: noop, - apiPackage: (apiPackage) => internalChildrenTraverser(apiPackage.children, [...parents, apiPackage]), - endpointPair: (endpointPair) => { - const result = internalTraverser(endpointPair.nonStream, undefined, [...parents, endpointPair]); - if (result === STOP) { - return STOP; - } - return internalTraverser(endpointPair.stream, undefined, [...parents, endpointPair]); - }, - unversioned: (unversioned) => { - if (unversioned.landingPage != null) { - const result = internalTraverser(unversioned.landingPage, undefined, [...parents, unversioned]); - if (result === STOP) { - return STOP; - } - } - - return internalTraverser(unversioned.child, undefined, [...parents, unversioned]); - }, - }); - } - internalTraverser(node, undefined, []); -} - -export function traverseNavigationLevelOrder( - node: NavigationNode, - visit: (node: NavigationNode, parent: NavigationNodeWithChildren[]) => typeof SKIP | void, -) { - const queue: [NavigationNode, NavigationNodeWithChildren[]][] = [[node, []]]; - while (queue.length > 0) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const [node, parents] = queue.shift()!; - - const result = visit(node, parents); - if (result === SKIP) { - continue; - } - - if (isLeaf(node)) { - continue; - } - - visitDiscriminatedUnion(node)._visit({ - root: (root) => queue.push([root.child, [...parents, root]]), - product: (product) => queue.push([product.child, [...parents, product]]), - productgroup: (productgroup) => { - if (productgroup.landingPage) { - queue.push([productgroup.landingPage, [...parents, productgroup]]); - } - - for (const child of productgroup.children) { - queue.push([child, [...parents, productgroup]]); - } - }, - versioned: (versioned) => { - for (const child of versioned.children) { - queue.push([child, [...parents, versioned]]); - } - }, - tabbed: (tabbed) => { - for (const child of tabbed.children) { - queue.push([child, [...parents, tabbed]]); - } - }, - sidebarRoot: (sidebar) => { - for (const child of sidebar.children) { - queue.push([child, [...parents, sidebar]]); - } - }, - sidebarGroup: (sidebarGroup) => { - for (const child of sidebarGroup.children) { - queue.push([child, [...parents, sidebarGroup]]); - } - }, - version: (version) => { - if (version.landingPage != null) { - queue.push([version.landingPage, [...parents, version]]); - } - queue.push([version.child, [...parents, version]]); - }, - tab: (tab) => { - queue.push([tab.child, [...parents, tab]]); - }, - section: (section) => { - for (const child of section.children) { - queue.push([child, [...parents, section]]); - } - }, - apiReference: (apiReference) => { - for (const child of apiReference.children) { - queue.push([child, [...parents, apiReference]]); - } - if (apiReference.changelog != null) { - queue.push([apiReference.changelog, [...parents, apiReference]]); - } - }, - changelog: (changelog) => { - for (const child of changelog.children) { - queue.push([child, [...parents, changelog]]); - } - }, - changelogYear: (changelogYear) => { - for (const child of changelogYear.children) { - queue.push([child, [...parents, changelogYear]]); - } - }, - changelogMonth: (changelogMonth) => { - for (const child of changelogMonth.children) { - queue.push([child, [...parents, changelogMonth]]); - } - }, - apiPackage: (apiPackage) => { - for (const child of apiPackage.children) { - queue.push([child, [...parents, apiPackage]]); - } - }, - endpointPair: (endpointPair) => { - queue.push([endpointPair.nonStream, [...parents, endpointPair]]); - queue.push([endpointPair.stream, [...parents, endpointPair]]); - }, - unversioned: (unversioned) => { - if (unversioned.landingPage != null) { - queue.push([unversioned.landingPage, [...parents, unversioned]]); - } - queue.push([unversioned.child, [...parents, unversioned]]); - }, - }); - } -} diff --git a/packages/fdr-sdk/src/utils/traversers/__test__/bfs.test.ts b/packages/fdr-sdk/src/utils/traversers/__test__/bfs.test.ts new file mode 100644 index 0000000000..88b88955cf --- /dev/null +++ b/packages/fdr-sdk/src/utils/traversers/__test__/bfs.test.ts @@ -0,0 +1,71 @@ +import { bfs } from "../bfs"; +import { FIXTURE } from "./fixture"; + +describe("bfs", () => { + it("should traverse the tree in breadth-first order", () => { + const visited: [number, number[]][] = []; + + bfs( + FIXTURE, + (n, p) => { + visited.push([n.id, p.map((n) => n.id)]); + }, + (n) => n.children, + ); + + expect(visited).toStrictEqual([ + [0, []], + [1, [0]], + [6, [0]], + [2, [0, 1]], + [4, [0, 1]], + [7, [0, 6]], + [3, [0, 1, 2]], + [5, [0, 1, 4]], + ]); + }); + + it("should skip nodes if the visitor returns 'skip'", () => { + const visited: [number, number[]][] = []; + + bfs( + FIXTURE, + (n, p) => { + visited.push([n.id, p.map((n) => n.id)]); + if (n.id === 1) { + return "skip"; + } + return; + }, + (p) => p.children, + ); + + expect(visited).toStrictEqual([ + [0, []], + [1, [0]], + [6, [0]], + [7, [0, 6]], + ]); + }); + + it("should stop traversal if the visitor returns false", () => { + const visited: [number, number[]][] = []; + + bfs( + FIXTURE, + (n, p) => { + visited.push([n.id, p.map((n) => n.id)]); + if (n.id === 1) { + return false; + } + return; + }, + (p) => p.children, + ); + + expect(visited).toStrictEqual([ + [0, []], + [1, [0]], + ]); + }); +}); diff --git a/packages/fdr-sdk/src/utils/traversers/__test__/dfs.test.ts b/packages/fdr-sdk/src/utils/traversers/__test__/dfs.test.ts new file mode 100644 index 0000000000..7be1f41173 --- /dev/null +++ b/packages/fdr-sdk/src/utils/traversers/__test__/dfs.test.ts @@ -0,0 +1,69 @@ +import { dfs } from "../dfs"; +import { FIXTURE } from "./fixture"; + +describe("dfs", () => { + it("should traverse the tree in breadth-first order", () => { + const visited: [number, number[]][] = []; + + dfs( + FIXTURE, + (n, p) => { + visited.push([n.id, p.map((n) => n.id)]); + }, + (n) => n.children, + ); + + expect(visited).toStrictEqual([ + [0, []], + [1, [0]], + [2, [0, 1]], + [3, [0, 1, 2]], + [4, [0, 1]], + [5, [0, 1, 4]], + [6, [0]], + [7, [0, 6]], + ]); + }); + + it("should skip nodes if the visitor returns 'skip'", () => { + const visited: [number, number[]][] = []; + dfs( + FIXTURE, + (n, p) => { + visited.push([n.id, p.map((n) => n.id)]); + if (n.id === 1) { + return "skip"; + } + return; + }, + (p) => p.children, + ); + + expect(visited).toStrictEqual([ + [0, []], + [1, [0]], + [6, [0]], + [7, [0, 6]], + ]); + }); + + it("should stop traversal if the visitor returns false", () => { + const visited: [number, number[]][] = []; + dfs( + FIXTURE, + (n, p) => { + visited.push([n.id, p.map((n) => n.id)]); + if (n.id === 1) { + return false; + } + return; + }, + (p) => p.children, + ); + + expect(visited).toStrictEqual([ + [0, []], + [1, [0]], + ]); + }); +}); diff --git a/packages/fdr-sdk/src/utils/traversers/__test__/fixture.ts b/packages/fdr-sdk/src/utils/traversers/__test__/fixture.ts new file mode 100644 index 0000000000..d498e55f8d --- /dev/null +++ b/packages/fdr-sdk/src/utils/traversers/__test__/fixture.ts @@ -0,0 +1,42 @@ +export interface Record { + id: number; + children: Record[]; +} + +export const FIXTURE: Record = { + id: 0, + children: [ + { + id: 1, + children: [ + { + id: 2, + children: [ + { + id: 3, + children: [], + }, + ], + }, + { + id: 4, + children: [ + { + id: 5, + children: [], + }, + ], + }, + ], + }, + { + id: 6, + children: [ + { + id: 7, + children: [], + }, + ], + }, + ], +}; diff --git a/packages/fdr-sdk/src/utils/traversers/__test__/prunetree.test.ts b/packages/fdr-sdk/src/utils/traversers/__test__/prunetree.test.ts new file mode 100644 index 0000000000..03604c4454 --- /dev/null +++ b/packages/fdr-sdk/src/utils/traversers/__test__/prunetree.test.ts @@ -0,0 +1,95 @@ +import { prunetree } from "../prunetree"; +import { FIXTURE, Record } from "./fixture"; + +describe("prunetree", () => { + it("should return the same tree if the predicate returns true for all nodes", () => { + const [pruned] = prunetree(structuredClone(FIXTURE), { + predicate: () => { + return true; + }, + getChildren: (node) => node.children, + deleter: (parent, child) => { + parent.children = parent.children.filter((c) => c.id !== child.id); + return child.id; + }, + getPointer: (node) => node.id, + }); + expect(pruned).toStrictEqual(FIXTURE); + }); + + it("should return undefined if the predicate returns false for all nodes", () => { + const [pruned] = prunetree(structuredClone(FIXTURE), { + predicate: () => { + return false; + }, + getChildren: (node) => node.children, + deleter: (parent, child) => { + parent.children = parent.children.filter((c) => c.id !== child.id); + return child.id; + }, + getPointer: (node) => node.id, + }); + expect(pruned).toBeUndefined(); + }); + + it("should prune the tree if the predicate returns false for some nodes", () => { + const [pruned] = prunetree(structuredClone(FIXTURE), { + predicate: (node) => { + return node.id !== 1; + }, + getChildren: (node) => node.children, + deleter: (parent, child) => { + parent.children = parent.children.filter((c) => c.id !== child.id); + return child.id; + }, + getPointer: (node) => node.id, + }); + expect(pruned).toStrictEqual({ + id: 0, + children: [ + { + id: 6, + children: [ + { + id: 7, + children: [], + }, + ], + }, + ], + }); + }); + + it("should prune parents that don't have children, but not leaf nodes", () => { + const [pruned] = prunetree(structuredClone(FIXTURE), { + predicate: (node) => { + return node.id !== 7 && node.id !== 3; + }, + getChildren: (node) => node.children, + deleter: (parent, child) => { + parent.children = parent.children.filter((c) => c.id !== child.id); + return child.id; + }, + getPointer: (node) => node.id, + }); + expect(pruned).toStrictEqual({ + id: 0, + children: [ + { + id: 1, + children: [ + { + id: 4, + children: [ + { + id: 5, + children: [], + }, + ], + }, + ], + }, + ], + }); + }); +}); diff --git a/packages/fdr-sdk/src/utils/traversers/bfs.ts b/packages/fdr-sdk/src/utils/traversers/bfs.ts new file mode 100644 index 0000000000..ff82789b41 --- /dev/null +++ b/packages/fdr-sdk/src/utils/traversers/bfs.ts @@ -0,0 +1,27 @@ +import { SKIP, STOP, TraverserGetChildren, TraverserVisit } from "./types"; + +export function bfs( + root: N, + visit: TraverserVisit, + getChildren: TraverserGetChildren, + isParent: (node: N) => node is P = (node): node is P => true, +): void { + const queue: [N, P[]][] = [[root, []]]; + while (queue.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const [node, parents] = queue.shift()!; + const next = visit(node, parents); + + if (next === SKIP) { + continue; + } else if (next === STOP) { + return; + } + + if (isParent(node)) { + for (const child of [...getChildren(node)]) { + queue.push([child, [...parents, node]]); + } + } + } +} diff --git a/packages/fdr-sdk/src/utils/traversers/dfs.ts b/packages/fdr-sdk/src/utils/traversers/dfs.ts new file mode 100644 index 0000000000..9639a948b8 --- /dev/null +++ b/packages/fdr-sdk/src/utils/traversers/dfs.ts @@ -0,0 +1,26 @@ +import { SKIP, STOP, TraverserGetChildren, TraverserVisit } from "./types"; + +export function dfs( + root: N, + visit: TraverserVisit, + getChildren: TraverserGetChildren, + isParent: (node: N) => node is P = (node): node is P => true, +): void { + const stack: [N, P[]][] = [[root, []]]; + while (stack.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const [node, parents] = stack.pop()!; + const next = visit(node, parents); + if (next === SKIP) { + continue; + } else if (next === STOP) { + return; + } + + if (isParent(node)) { + for (const child of [...getChildren(node)].reverse()) { + stack.push([child, [...parents, node]]); + } + } + } +} diff --git a/packages/fdr-sdk/src/utils/traversers/prunetree.ts b/packages/fdr-sdk/src/utils/traversers/prunetree.ts new file mode 100644 index 0000000000..73b2937649 --- /dev/null +++ b/packages/fdr-sdk/src/utils/traversers/prunetree.ts @@ -0,0 +1,121 @@ +import { bfs } from "./bfs"; + +interface PruneTreeOptions { + /** + * @param node the node to check + * @returns **false** if the node SHOULD be deleted + */ + predicate: (node: NODE) => boolean; + getChildren: (node: PARENT) => readonly NODE[]; + + /** + * @param parent the parent node + * @param child the child that should be deleted + * @returns the pointer to the child node, or **null** if the child cannot be deleted + */ + deleter: (parent: PARENT, child: NODE) => POINTER | null; + + /** + * After the child is deleted, we can check if the parent should be deleted too, + * e.g. if the parent has no children left. + * + * @param parent node + * @returns **true** if the node should be deleted + * @default parent => getChildren(parent).length === 0 + */ + shouldDeleteParent?: (parent: PARENT) => boolean; + + /** + * If there are circular references, we can use this function to get a unique identifier for the node. + * + * @param node + * @returns the unique identifier for the node + * @default node => node as unknown as POINTER (the reference itself is used as the identifier) + */ + getPointer?: (node: NODE) => POINTER; +} + +export function prunetree( + root: ROOT, + opts: PruneTreeOptions, +): [result: ROOT | undefined, deleted: ReadonlySet] { + const { + predicate, + getChildren, + deleter, + shouldDeleteParent = (parent) => getChildren(parent).length === 0, + getPointer = (node) => node as unknown as POINTER, + } = opts; + + const deleted = new Set(); + + const visitor = (node: NODE, parents: readonly PARENT[]) => { + // if the node or its parents was already deleted, we don't need to traverse it + if ([...parents, node].some((parent) => deleted.has(getPointer(parent)))) { + return "skip"; + } + + // continue traversal if the node is not to be deleted + if (predicate(node)) { + return; + } + + deleteChildAndMaybeParent(node, parents, { + deleter, + shouldDeleteParent, + getPointer, + }).forEach((id) => { + deleted.add(id); + }); + + // since the node was deleted, its children are deleted too + // we don't need to traverse them, nor do we need to keep them in the tree. + // note: the deleted set will NOT contain the children of this node + return "skip"; + }; + + bfs(root, visitor, getChildren); + + if (deleted.has(getPointer(root))) { + return [undefined, deleted]; + } + + return [root, deleted]; +} + +interface DeleteChildOptions { + deleter: (parent: PARENT, child: NODE) => POINTER | null; + shouldDeleteParent: (parent: PARENT) => boolean; + getPointer: (node: NODE) => POINTER; +} + +function deleteChildAndMaybeParent( + node: NODE, + parents: readonly PARENT[], + opts: DeleteChildOptions, +): POINTER[] { + const { deleter, shouldDeleteParent, getPointer } = opts; + + const ancestors = [...parents]; + const parent = ancestors.pop(); + + // if the parent is the root, we cannot delete it here + // so we mark it as deleted and the parent function will be responsible for deleting it + if (parent == null) { + return [getPointer(node)]; + } + + const deleted = deleter(parent, node); + + // if the node was not deletable, then we need to delete the parent too + if (deleted == null) { + return [getPointer(node), ...deleteChildAndMaybeParent(parent, ancestors, opts)]; + } + + // traverse up the tree and delete the parent if necessary + if (shouldDeleteParent(parent)) { + return [getPointer(node), deleted, ...deleteChildAndMaybeParent(parent, ancestors, opts)]; + } + + return [getPointer(node), deleted]; +} diff --git a/packages/fdr-sdk/src/utils/traversers/types.ts b/packages/fdr-sdk/src/utils/traversers/types.ts new file mode 100644 index 0000000000..5ca0f697d4 --- /dev/null +++ b/packages/fdr-sdk/src/utils/traversers/types.ts @@ -0,0 +1,8 @@ +export const SKIP = "skip" as const; +export const STOP = false; +export const CONTINUE = true; + +export type Next = typeof CONTINUE | typeof SKIP | typeof STOP | void; + +export type TraverserVisit = (node: N, parents: readonly P[]) => Next; +export type TraverserGetChildren = (parent: P) => readonly N[]; diff --git a/packages/ui/app/src/atoms/sidebar.ts b/packages/ui/app/src/atoms/sidebar.ts index 8bafd0280d..05fdf9f707 100644 --- a/packages/ui/app/src/atoms/sidebar.ts +++ b/packages/ui/app/src/atoms/sidebar.ts @@ -24,7 +24,7 @@ export const SIDEBAR_CHILD_TO_PARENTS_MAP_ATOM = atom((get) => { return childToParentsMap; } - FernNavigation.traverseNavigation(sidebar, (node, _index, parents) => { + FernNavigation.traverseDF(sidebar, (node, parents) => { childToParentsMap.set( node.id, parents.map((p) => p.id), @@ -69,7 +69,7 @@ const INTERNAL_EXPANDED_SIDEBAR_NODES_ATOM = atomWithDefault<{ // the following was commented out because FDR stores `collapsed: false` by default. Another solution is needed. // const sidebar = get(SIDEBAR_ROOT_NODE_ATOM); // if (sidebar != null) { - // FernNavigation.traverseNavigation(sidebar, (node) => { + // FernNavigation.traverseDF(sidebar, (node) => { // // TODO: check for api reference, etc. // if (node.type === "section" && node.collapsed === false) { // expandedNodes.add(node.id); diff --git a/packages/ui/app/src/util/resolveDocsContent.ts b/packages/ui/app/src/util/resolveDocsContent.ts index 1150622e39..e0e4b9cb53 100644 --- a/packages/ui/app/src/util/resolveDocsContent.ts +++ b/packages/ui/app/src/util/resolveDocsContent.ts @@ -72,7 +72,7 @@ export async function resolveDocsContent({ if (node.type === "changelog") { const pageIds = new Set(); - FernNavigation.traverseNavigation(node, (n) => { + FernNavigation.traverseDF(node, (n) => { if (FernNavigation.hasMarkdown(n)) { const pageId = FernNavigation.getPageId(n); if (pageId != null) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0e13ee1ce..b9a56ee13d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -446,7 +446,7 @@ importers: version: 3.3.2 simple-git: specifier: ^3.24.0 - version: 3.24.0 + version: 3.24.0(supports-color@8.1.1) stylelint: specifier: ^16.1.0 version: 16.5.0(typescript@5.4.3) @@ -817,6 +817,9 @@ importers: '@fern-ui/core-utils': specifier: workspace:* version: link:../commons/core-utils + core-js-pure: + specifier: ^3.38.1 + version: 3.38.1 dayjs: specifier: ^1.11.11 version: 1.11.11 @@ -2657,7 +2660,7 @@ importers: version: 3.21.0(serverless@3.38.0) simple-git: specifier: ^3.24.0 - version: 3.24.0 + version: 3.24.0(supports-color@8.1.1) tmp-promise: specifier: ^3.0.3 version: 3.0.3 @@ -9489,6 +9492,9 @@ packages: core-js-pure@3.37.0: resolution: {integrity: sha512-d3BrpyFr5eD4KcbRvQ3FTUx/KWmaDesr7+a3+1+P46IUnNoEt+oiLijPINZMEon7w9oGkIINWxrBAU9DEciwFQ==} + core-js-pure@3.38.1: + resolution: {integrity: sha512-BY8Etc1FZqdw1glX0XNOq2FDwfrg/VGqoZOZCdaL+UmdaqDwQwYXkMJT4t6In+zfEfOJDcM9T0KdbBeJg8KKCQ==} + core-js@3.37.0: resolution: {integrity: sha512-fu5vHevQ8ZG4og+LXug8ulUtVxjOcEYvifJr7L5Bfq9GOztVqsKd9/59hUk2ZSbCrS3BqUr3EpaYGIYzq7g3Ug==} @@ -17681,7 +17687,7 @@ snapshots: '@babel/traverse': 7.24.5 '@babel/types': 7.24.5 convert-source-map: 2.0.0 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -18480,7 +18486,7 @@ snapshots: '@babel/helper-split-export-declaration': 7.24.5 '@babel/parser': 7.24.5 '@babel/types': 7.24.5 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -18495,7 +18501,7 @@ snapshots: '@babel/helper-split-export-declaration': 7.24.5 '@babel/parser': 7.24.5 '@babel/types': 7.24.5 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -18719,7 +18725,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.12.6 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) espree: 9.6.1 globals: 13.24.0 ignore: 5.3.1 @@ -18982,7 +18988,7 @@ snapshots: '@humanwhocodes/config-array@0.11.14': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -19450,12 +19456,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.4.15 - '@kwsites/file-exists@1.1.1': - dependencies: - debug: 4.3.4(supports-color@5.5.0) - transitivePeerDependencies: - - supports-color - '@kwsites/file-exists@1.1.1(supports-color@8.1.1)': dependencies: debug: 4.3.4(supports-color@8.1.1) @@ -24171,7 +24171,7 @@ snapshots: '@typescript-eslint/type-utils': 7.3.1(eslint@8.57.0)(typescript@5.4.3) '@typescript-eslint/utils': 7.3.1(eslint@8.57.0)(typescript@5.4.3) '@typescript-eslint/visitor-keys': 7.3.1 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) eslint: 8.57.0 graphemer: 1.4.0 ignore: 5.3.1 @@ -24189,7 +24189,7 @@ snapshots: '@typescript-eslint/types': 7.17.0 '@typescript-eslint/typescript-estree': 7.17.0(typescript@5.4.3) '@typescript-eslint/visitor-keys': 7.17.0 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) eslint: 8.57.0 optionalDependencies: typescript: 5.4.3 @@ -24202,7 +24202,7 @@ snapshots: '@typescript-eslint/types': 7.2.0 '@typescript-eslint/typescript-estree': 7.2.0(typescript@5.4.3) '@typescript-eslint/visitor-keys': 7.2.0 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) eslint: 8.57.0 optionalDependencies: typescript: 5.4.3 @@ -24215,7 +24215,7 @@ snapshots: '@typescript-eslint/types': 7.3.1 '@typescript-eslint/typescript-estree': 7.3.1(typescript@5.4.3) '@typescript-eslint/visitor-keys': 7.3.1 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) eslint: 8.57.0 optionalDependencies: typescript: 5.4.3 @@ -24268,7 +24268,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 7.3.1(typescript@5.4.3) '@typescript-eslint/utils': 7.3.1(eslint@8.57.0)(typescript@5.4.3) - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) eslint: 8.57.0 ts-api-utils: 1.3.0(typescript@5.4.3) optionalDependencies: @@ -24351,7 +24351,7 @@ snapshots: dependencies: '@typescript-eslint/types': 7.3.1 '@typescript-eslint/visitor-keys': 7.3.1 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.3 @@ -25787,7 +25787,7 @@ snapshots: agent-base@7.1.1: dependencies: - debug: 4.3.6(supports-color@8.1.1) + debug: 4.3.7 transitivePeerDependencies: - supports-color @@ -26661,7 +26661,7 @@ snapshots: chokidar@3.6.0: dependencies: anymatch: 3.1.3 - braces: 3.0.2 + braces: 3.0.3 glob-parent: 5.1.2 is-binary-path: 2.1.0 is-glob: 4.0.3 @@ -26983,6 +26983,8 @@ snapshots: core-js-pure@3.37.0: {} + core-js-pure@3.38.1: {} + core-js@3.37.0: {} core-util-is@1.0.3: {} @@ -27435,7 +27437,7 @@ snapshots: callsite: 1.0.0 camelcase: 6.3.0 cosmiconfig: 7.1.0 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) deps-regex: 0.2.0 findup-sync: 5.0.0 ignore: 5.3.1 @@ -27925,7 +27927,7 @@ snapshots: eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0): dependencies: - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) enhanced-resolve: 5.16.1 eslint: 8.57.0 eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0) @@ -28139,7 +28141,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.3 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -29366,7 +29368,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.1 - debug: 4.3.6(supports-color@8.1.1) + debug: 4.3.7 transitivePeerDependencies: - supports-color @@ -29406,7 +29408,7 @@ snapshots: https-proxy-agent@7.0.5: dependencies: agent-base: 7.1.1 - debug: 4.3.6(supports-color@8.1.1) + debug: 4.3.7 transitivePeerDependencies: - supports-color @@ -29571,7 +29573,7 @@ snapshots: dependencies: '@ioredis/commands': 1.2.0 cluster-key-slot: 1.1.2 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) denque: 2.1.0 lodash.defaults: 4.2.0 lodash.isarguments: 3.1.0 @@ -30534,7 +30536,7 @@ snapshots: dependencies: chalk: 5.3.0 commander: 11.0.0 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) execa: 7.2.0 lilconfig: 2.1.0 listr2: 6.6.1 @@ -33972,14 +33974,6 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 - simple-git@3.24.0: - dependencies: - '@kwsites/file-exists': 1.1.1 - '@kwsites/promise-deferred': 1.1.1 - debug: 4.3.4(supports-color@5.5.0) - transitivePeerDependencies: - - supports-color - simple-git@3.24.0(supports-color@8.1.1): dependencies: '@kwsites/file-exists': 1.1.1(supports-color@8.1.1) @@ -34420,7 +34414,7 @@ snapshots: cosmiconfig: 9.0.0(typescript@5.4.3) css-functions-list: 3.2.2 css-tree: 2.3.1 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) fast-glob: 3.3.2 fastest-levenshtein: 1.0.16 file-entry-cache: 8.0.0 @@ -34458,7 +34452,7 @@ snapshots: stylus@0.62.0: dependencies: '@adobe/css-tools': 4.3.3 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.7 glob: 7.2.3 sax: 1.3.0 source-map: 0.7.4 @@ -35055,7 +35049,7 @@ snapshots: bundle-require: 4.1.0(esbuild@0.20.2) cac: 6.7.14 chokidar: 3.6.0 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) esbuild: 0.20.2 execa: 5.1.1 globby: 11.1.0 @@ -35577,7 +35571,7 @@ snapshots: vite-node@1.6.0(@types/node@18.19.33)(less@4.2.0)(sass@1.77.0)(stylus@0.62.0)(terser@5.31.0): dependencies: cac: 6.7.14 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) pathe: 1.1.2 picocolors: 1.0.0 vite: 5.2.11(@types/node@18.19.33)(less@4.2.0)(sass@1.77.0)(stylus@0.62.0)(terser@5.31.0) @@ -35594,7 +35588,7 @@ snapshots: vite-node@1.6.0(@types/node@22.5.5)(less@4.2.0)(sass@1.77.0)(stylus@0.62.0)(terser@5.31.0): dependencies: cac: 6.7.14 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) pathe: 1.1.2 picocolors: 1.0.0 vite: 5.2.11(@types/node@22.5.5)(less@4.2.0)(sass@1.77.0)(stylus@0.62.0)(terser@5.31.0) @@ -35718,7 +35712,7 @@ snapshots: '@vitest/utils': 1.6.0 acorn-walk: 8.3.2 chai: 4.4.1 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) execa: 8.0.1 local-pkg: 0.5.0 magic-string: 0.30.10 @@ -35753,7 +35747,7 @@ snapshots: '@vitest/utils': 1.6.0 acorn-walk: 8.3.2 chai: 4.4.1 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) execa: 8.0.1 local-pkg: 0.5.0 magic-string: 0.30.10 From 6397cc613a056b908ea24ee26efb2e5668a2ebf7 Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Fri, 4 Oct 2024 15:06:34 -0400 Subject: [PATCH 07/27] add more unit tests --- .../__test__/pruneNavigationTree.test.ts | 211 ++++++++++++++++++ .../src/navigation/utils/deleteChild.ts | 75 +++++-- .../versions/latest/NavigationNodePage.ts | 13 +- .../traversers/__test__/prunetree.test.ts | 67 +++--- .../fdr-sdk/src/utils/traversers/prunetree.ts | 33 ++- .../fdr-sdk/src/utils/traversers/types.ts | 6 +- 6 files changed, 322 insertions(+), 83 deletions(-) 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 0dfd3494ac..092d2f69e5 100644 --- a/packages/fdr-sdk/src/navigation/utils/__test__/pruneNavigationTree.test.ts +++ b/packages/fdr-sdk/src/navigation/utils/__test__/pruneNavigationTree.test.ts @@ -95,4 +95,215 @@ describe("pruneNavigationTree", () => { 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: 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: undefined, + }); + }); + + 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/deleteChild.ts b/packages/fdr-sdk/src/navigation/utils/deleteChild.ts index bd04391243..6aebaa9f42 100644 --- a/packages/fdr-sdk/src/navigation/utils/deleteChild.ts +++ b/packages/fdr-sdk/src/navigation/utils/deleteChild.ts @@ -1,5 +1,6 @@ -import { UnreachableCaseError } from "ts-essentials"; +import { MarkOptional, UnreachableCaseError } from "ts-essentials"; import { FernNavigation } from "../.."; +import { DeleterAction } from "../../utils/traversers/types"; /** * @param parent delete node from this parent (mutable) @@ -7,65 +8,95 @@ import { FernNavigation } from "../.."; * @returns the id of the deleted node or null if the node was not deletable from the parent */ export function mutableDeleteChild( - parent: FernNavigation.NavigationNodeParent, + parent: FernNavigation.NavigationNodeParent | undefined, node: FernNavigation.NavigationNode, -): FernNavigation.NodeId | null { +): 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; + 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); - return node.id; + return "deleted"; case "apiReference": parent.children = parent.children.filter((child) => child.id !== node.id); parent.changelog = parent.changelog?.id === node.id ? undefined : parent.changelog; - return node.id; + return "deleted"; case "changelog": parent.children = parent.children.filter((child) => child.id !== node.id); - return node.id; + return "deleted"; case "changelogYear": parent.children = parent.children.filter((child) => child.id !== node.id); - return node.id; + return "deleted"; case "changelogMonth": parent.children = parent.children.filter((child) => child.id !== node.id); - return node.id; + return "deleted"; case "endpointPair": - return null; + 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; - return node.id; + return "deleted"; case "product": - return null; + return "should-delete-parent"; case "root": - return null; + return "should-delete-parent"; case "unversioned": if (node.id === parent.landingPage?.id) { parent.landingPage = undefined; - return node.id; + return "deleted"; } - return null; + return "should-delete-parent"; case "section": parent.children = parent.children.filter((child) => child.id !== node.id); - return node.id; + return "deleted"; case "sidebarGroup": parent.children = parent.children.filter((child) => child.id !== node.id); - return node.id; + return "deleted"; case "tab": - return null; + return "should-delete-parent"; case "sidebarRoot": parent.children = parent.children.filter((child) => child.id !== node.id); - return node.id; + return "deleted"; case "tabbed": parent.children = parent.children.filter((child) => child.id !== node.id); - return node.id; + return "deleted"; case "version": if (node.id === parent.landingPage?.id) { parent.landingPage = undefined; - return node.id; + return "deleted"; } - return null; + return "should-delete-parent"; case "versioned": parent.children = parent.children.filter((child) => child.id !== node.id); - return node.id; + return "deleted"; default: throw new UnreachableCaseError(parent); } 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/utils/traversers/__test__/prunetree.test.ts b/packages/fdr-sdk/src/utils/traversers/__test__/prunetree.test.ts index 03604c4454..fbf5f020ca 100644 --- a/packages/fdr-sdk/src/utils/traversers/__test__/prunetree.test.ts +++ b/packages/fdr-sdk/src/utils/traversers/__test__/prunetree.test.ts @@ -1,48 +1,43 @@ 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); + 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] = prunetree(structuredClone(FIXTURE), { - predicate: () => { - return true; - }, - getChildren: (node) => node.children, - deleter: (parent, child) => { - parent.children = parent.children.filter((c) => c.id !== child.id); - return child.id; - }, - getPointer: (node) => node.id, + const pruned = testPruner(() => { + return true; }); expect(pruned).toStrictEqual(FIXTURE); }); it("should return undefined if the predicate returns false for all nodes", () => { - const [pruned] = prunetree(structuredClone(FIXTURE), { - predicate: () => { - return false; - }, - getChildren: (node) => node.children, - deleter: (parent, child) => { - parent.children = parent.children.filter((c) => c.id !== child.id); - return child.id; - }, - getPointer: (node) => node.id, + const pruned = testPruner(() => { + return false; }); expect(pruned).toBeUndefined(); }); it("should prune the tree if the predicate returns false for some nodes", () => { - const [pruned] = prunetree(structuredClone(FIXTURE), { - predicate: (node) => { - return node.id !== 1; - }, - getChildren: (node) => node.children, - deleter: (parent, child) => { - parent.children = parent.children.filter((c) => c.id !== child.id); - return child.id; - }, - getPointer: (node) => node.id, + const pruned = testPruner((node) => { + return node.id !== 1; }); expect(pruned).toStrictEqual({ id: 0, @@ -61,16 +56,8 @@ describe("prunetree", () => { }); it("should prune parents that don't have children, but not leaf nodes", () => { - const [pruned] = prunetree(structuredClone(FIXTURE), { - predicate: (node) => { - return node.id !== 7 && node.id !== 3; - }, - getChildren: (node) => node.children, - deleter: (parent, child) => { - parent.children = parent.children.filter((c) => c.id !== child.id); - return child.id; - }, - getPointer: (node) => node.id, + const pruned = testPruner((node) => { + return node.id !== 7 && node.id !== 3; }); expect(pruned).toStrictEqual({ id: 0, diff --git a/packages/fdr-sdk/src/utils/traversers/prunetree.ts b/packages/fdr-sdk/src/utils/traversers/prunetree.ts index 73b2937649..815e021ff2 100644 --- a/packages/fdr-sdk/src/utils/traversers/prunetree.ts +++ b/packages/fdr-sdk/src/utils/traversers/prunetree.ts @@ -1,4 +1,6 @@ +import { UnreachableCaseError } from "ts-essentials"; import { bfs } from "./bfs"; +import { DeleterAction } from "./types"; interface PruneTreeOptions { /** @@ -13,7 +15,7 @@ interface PruneTreeOptions { * @param child the child that should be deleted * @returns the pointer to the child node, or **null** if the child cannot be deleted */ - deleter: (parent: PARENT, child: NODE) => POINTER | null; + deleter: (parent: PARENT | undefined, child: NODE) => DeleterAction; /** * After the child is deleted, we can check if the parent should be deleted too, @@ -84,7 +86,7 @@ export function prunetree { - deleter: (parent: PARENT, child: NODE) => POINTER | null; + deleter: (parent: PARENT | undefined, child: NODE) => DeleterAction; shouldDeleteParent: (parent: PARENT) => boolean; getPointer: (node: NODE) => POINTER; } @@ -99,23 +101,32 @@ function deleteChildAndMaybeParent = (node: N, parents: readonly P[]) => Next; +export type TraverserVisit = (node: N, parents: readonly P[]) => Action; export type TraverserGetChildren = (parent: P) => readonly N[]; + +export type DeleterAction = "deleted" | "should-delete-parent" | "noop"; From 2fbb6114dab6ca059e7e16d45c5abb8636631716 Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Fri, 4 Oct 2024 15:08:29 -0400 Subject: [PATCH 08/27] fix breaks --- packages/fdr-sdk/src/navigation/utils/followRedirect.ts | 2 ++ packages/fdr-sdk/src/navigation/versions/latest/getPageId.ts | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/fdr-sdk/src/navigation/utils/followRedirect.ts b/packages/fdr-sdk/src/navigation/utils/followRedirect.ts index d29fd5a623..e27f596759 100644 --- a/packages/fdr-sdk/src/navigation/utils/followRedirect.ts +++ b/packages/fdr-sdk/src/navigation/utils/followRedirect.ts @@ -23,6 +23,8 @@ export function followRedirect( 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": 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, }); } From 8b5abbe483adad9a3686d0cef7084446607ac0a7 Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Fri, 4 Oct 2024 15:25:16 -0400 Subject: [PATCH 09/27] improve performance by using stack instead of recursion --- .../versions/v1/NavigationNodeLeaf.ts | 5 +- .../converters/NavigationConfigConverter.ts | 2 +- .../src/navigation/versions/v1/getChildren.ts | 37 ++++++ .../src/navigation/versions/v1/index.ts | 12 +- .../src/navigation/versions/v1/traverseDF.ts | 8 ++ .../versions/v1/traverseNavigation.ts | 107 ------------------ .../algolia/AlgoliaSearchRecordGenerator.ts | 8 +- .../algolia/AlgoliaSearchRecordGeneratorV2.ts | 6 +- 8 files changed, 62 insertions(+), 123 deletions(-) create mode 100644 packages/fdr-sdk/src/navigation/versions/v1/getChildren.ts create mode 100644 packages/fdr-sdk/src/navigation/versions/v1/traverseDF.ts delete mode 100644 packages/fdr-sdk/src/navigation/versions/v1/traverseNavigation.ts 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 53994bcf8e..0000000000 --- a/packages/fdr-sdk/src/navigation/versions/v1/traverseNavigation.ts +++ /dev/null @@ -1,107 +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: (productgroup) => { - if (productgroup.landingPage != null) { - const result = internalTraverser(productgroup.landingPage, undefined, [...parents, productgroup]); - if (result === STOP) { - return STOP; - } - } - return internalChildrenTraverser(productgroup.children, [...parents, productgroup]); - }, - versioned: (versioned) => internalChildrenTraverser(versioned.children, [...parents, versioned]), - tabbed: (tabbed) => internalChildrenTraverser(tabbed.children, [...parents, tabbed]), - sidebarRoot: (sidebar) => internalChildrenTraverser(sidebar.children, [...parents, sidebar]), - sidebarGroup: (sidebarGroup) => - internalChildrenTraverser(sidebarGroup.children, [...parents, sidebarGroup]), - version: (version) => { - if (version.landingPage != null) { - const result = internalTraverser(version.landingPage, undefined, [...parents, version]); - if (result === STOP) { - return STOP; - } - } - return internalTraverser(version.child, undefined, [...parents, version]); - }, - tab: (tab) => internalTraverser(tab.child, undefined, [...parents, tab]), - link: noop, - page: noop, - landingPage: noop, - section: (section) => internalChildrenTraverser(section.children, [...parents, section]), - apiReference: (apiReference) => { - const result = internalChildrenTraverser(apiReference.children, [...parents, apiReference]); - if (result === STOP) { - return STOP; - } - if (apiReference.changelog != null) { - return internalTraverser(apiReference.changelog, undefined, [...parents, apiReference]); - } - }, - changelog: (changelog) => internalChildrenTraverser(changelog.children, [...parents, changelog]), - changelogYear: (changelogYear) => - internalChildrenTraverser(changelogYear.children, [...parents, changelogYear]), - changelogMonth: (changelogMonth) => - internalChildrenTraverser(changelogMonth.children, [...parents, changelogMonth]), - changelogEntry: noop, - endpoint: noop, - webSocket: noop, - webhook: noop, - apiPackage: (apiPackage) => internalChildrenTraverser(apiPackage.children, [...parents, apiPackage]), - endpointPair: (endpointPair) => { - const result = internalTraverser(endpointPair.nonStream, undefined, [...parents, endpointPair]); - if (result === STOP) { - return STOP; - } - return internalTraverser(endpointPair.stream, undefined, [...parents, endpointPair]); - }, - unversioned: (unversioned) => { - if (unversioned.landingPage != null) { - const result = internalTraverser(unversioned.landingPage, undefined, [...parents, unversioned]); - if (result === STOP) { - return STOP; - } - } - - return internalTraverser(unversioned.child, undefined, [...parents, unversioned]); - }, - }); - } - internalTraverser(node, undefined, []); -} 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, From 5c8b1f071e256159baabc0ae148845ac5f4d4cc2 Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Fri, 4 Oct 2024 15:30:39 -0400 Subject: [PATCH 10/27] update unit tests --- .../__test__/pruneNavigationTree.test.ts | 55 ++++++++++++++++++- .../navigation/utils/pruneNavigationTree.ts | 8 +-- .../src/navigation/utils/updatePointsTo.ts | 3 +- 3 files changed, 57 insertions(+), 9 deletions(-) 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 092d2f69e5..2ec3a34327 100644 --- a/packages/fdr-sdk/src/navigation/utils/__test__/pruneNavigationTree.test.ts +++ b/packages/fdr-sdk/src/navigation/utils/__test__/pruneNavigationTree.test.ts @@ -59,7 +59,7 @@ describe("pruneNavigationTree", () => { hidden: undefined, overviewPageId: undefined, noindex: undefined, - pointsTo: undefined, + pointsTo: FernNavigation.Slug("root/page"), }); }); @@ -88,7 +88,7 @@ describe("pruneNavigationTree", () => { hidden: undefined, overviewPageId: undefined, noindex: undefined, - pointsTo: undefined, + pointsTo: FernNavigation.Slug("root/page"), }; const result = pruneNavigationTree(root, (node) => node.id !== FernNavigation.NodeId("page")); @@ -153,6 +153,55 @@ describe("pruneNavigationTree", () => { 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, }); }); @@ -214,7 +263,7 @@ describe("pruneNavigationTree", () => { icon: undefined, hidden: undefined, noindex: undefined, - pointsTo: undefined, + pointsTo: FernNavigation.Slug("root/page"), }); }); diff --git a/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts b/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts index 4ff3079a41..9f516f3ab0 100644 --- a/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts +++ b/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts @@ -23,7 +23,7 @@ function mutablePruneNavigationTree( root: ROOT, keep: (node: FernNavigation.NavigationNode) => boolean, ): ROOT | undefined { - const [result, deleted] = prunetree(root, { + const [result] = prunetree(root, { predicate: keep, getChildren: FernNavigation.getChildren, getPointer: (node) => node.id, @@ -38,10 +38,8 @@ function mutablePruneNavigationTree( return undefined; } - if (deleted.size > 0) { - // since the tree has been pruned, we need to update the pointsTo property - mutableUpdatePointsTo(result); - } + // 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/updatePointsTo.ts b/packages/fdr-sdk/src/navigation/utils/updatePointsTo.ts index 3e979adb9a..17b64fb1f5 100644 --- a/packages/fdr-sdk/src/navigation/utils/updatePointsTo.ts +++ b/packages/fdr-sdk/src/navigation/utils/updatePointsTo.ts @@ -9,7 +9,8 @@ import { followRedirect } from "./followRedirect"; export function mutableUpdatePointsTo(input: FernNavigation.NavigationNode): void { FernNavigation.traverseDF(input, (node) => { if (FernNavigation.hasPointsTo(node)) { - node.pointsTo = followRedirect(node); + const pointsTo = followRedirect(node); + node.pointsTo = node.slug === pointsTo ? undefined : pointsTo; } }); } From b49c7361baba85711d649bb9a86e1891972184f4 Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Fri, 4 Oct 2024 15:31:36 -0400 Subject: [PATCH 11/27] immutable reverse --- packages/ui/app/src/hooks/usePlaygroundSettings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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; From 90966f00920c27dcc48643969416c516dee1cbcc Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Fri, 4 Oct 2024 15:46:51 -0400 Subject: [PATCH 12/27] fixes --- .../api/fern-docs/auth/api-key-injection.ts | 4 +- .../pages/api/fern-docs/auth/jwt/callback.ts | 42 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 packages/ui/docs-bundle/src/pages/api/fern-docs/auth/jwt/callback.ts 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 29f5292056..6c6f1b700b 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,9 +1,9 @@ import { OAuth2Client } from "@/server/auth/OAuth2Client"; -import { APIKeyInjectionConfig, getAPIKeyInjectionConfig } from "@/server/auth/getApiKeyInjectionConfig"; +import { getAPIKeyInjectionConfig } from "@/server/auth/getApiKeyInjectionConfig"; import { getAuthEdgeConfig } from "@/server/auth/getAuthEdgeConfig"; import { withSecureCookie } from "@/server/auth/withSecure"; import { getXFernHostEdge } from "@/server/xfernhost/edge"; -import { OryAccessTokenSchema } 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"; 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..41e733a63e --- /dev/null +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/jwt/callback.ts @@ -0,0 +1,42 @@ +import { verifyFernJWTConfig } from "@/server/auth/FernJWT"; +import { getAuthEdgeConfig } from "@/server/auth/getAuthEdgeConfig"; +import { withSecureCookie } from "@/server/auth/withSecure"; +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); + + const token = req.nextUrl.searchParams.get("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("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"); + } +} From 45cdf96a90ea1f300ddc9e1288c27d96c7318ea3 Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Fri, 4 Oct 2024 15:54:30 -0400 Subject: [PATCH 13/27] add note --- packages/fdr-sdk/src/utils/traversers/prunetree.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/fdr-sdk/src/utils/traversers/prunetree.ts b/packages/fdr-sdk/src/utils/traversers/prunetree.ts index 815e021ff2..122d20ec5d 100644 --- a/packages/fdr-sdk/src/utils/traversers/prunetree.ts +++ b/packages/fdr-sdk/src/utils/traversers/prunetree.ts @@ -37,6 +37,8 @@ interface PruneTreeOptions { getPointer?: (node: NODE) => POINTER; } +// TODO: this algorithm is not optimal, as it traverses the tree twice, and should be refactored to traverse only once +// it would be more efficient to BFS the tree once, collect all the nodes in an array, and reverse the array to delete the nodes from the bottom up export function prunetree( root: ROOT, opts: PruneTreeOptions, From 88dcbf88a281b80d465c25edd5ac87f490757de9 Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Fri, 4 Oct 2024 16:14:51 -0400 Subject: [PATCH 14/27] fix --- packages/ui/app/src/playground/utils/flatten-apis.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"; } From cd4821ab35b3c53bf13164ed4cf3f410d022b306 Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Fri, 4 Oct 2024 16:18:21 -0400 Subject: [PATCH 15/27] fix --- packages/ui/app/src/seo/getBreadcrumbList.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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; From a9afae8a939f42ed724f3f9005803daef1cde9ad Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Fri, 4 Oct 2024 16:23:10 -0400 Subject: [PATCH 16/27] fix --- packages/ui/app/src/util/resolveDocsContent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/app/src/util/resolveDocsContent.ts b/packages/ui/app/src/util/resolveDocsContent.ts index e0e4b9cb53..0d58ac4699 100644 --- a/packages/ui/app/src/util/resolveDocsContent.ts +++ b/packages/ui/app/src/util/resolveDocsContent.ts @@ -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, }, From 8353b54a0fea4c9bf8f8f7632789e07cab798d31 Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Fri, 4 Oct 2024 16:29:14 -0400 Subject: [PATCH 17/27] ignore structuredClone --- packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts b/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts index 9f516f3ab0..50095f823b 100644 --- a/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts +++ b/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts @@ -1,4 +1,4 @@ -import structuredClone from "core-js-pure/actual/structured-clone"; +// import structuredClone from "core-js-pure/actual/structured-clone"; import { DeepReadonly } from "ts-essentials"; import { FernNavigation } from "../.."; import { prunetree } from "../../utils/traversers/prunetree"; From d953ef97db6997b0247edfb95a8bb205eb43c032 Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Fri, 4 Oct 2024 17:41:04 -0400 Subject: [PATCH 18/27] updates --- packages/fdr-sdk/package.json | 3 +- packages/fdr-sdk/src/declarations.d.ts | 5 --- .../navigation/utils/pruneNavigationTree.ts | 5 ++- packages/fdr-sdk/tsconfig.json | 3 +- packages/ui/docs-bundle/src/middleware.ts | 2 +- .../src/pages/api/fern-docs/auth/callback.ts | 2 +- .../pages/api/fern-docs/revalidate-all/v3.ts | 25 ++++++------ .../src/pages/api/fern-docs/sitemap.xml.ts | 11 +++--- .../src/server/auth/checkViewerAllowed.ts | 39 ++++++++----------- .../src/server/withBasicTokenViewAllowed.ts | 20 ++++++++++ .../src/server/withInitialProps.ts | 12 ++---- pnpm-lock.yaml | 19 +++++---- 12 files changed, 77 insertions(+), 69 deletions(-) delete mode 100644 packages/fdr-sdk/src/declarations.d.ts diff --git a/packages/fdr-sdk/package.json b/packages/fdr-sdk/package.json index e1c7bae3a4..b4fd0364e3 100644 --- a/packages/fdr-sdk/package.json +++ b/packages/fdr-sdk/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@fern-ui/core-utils": "workspace:*", - "core-js-pure": "^3.38.1", + "@ungap/structured-clone": "^1.2.0", "dayjs": "^1.11.11", "fast-deep-equal": "^3.1.3", "form-data": "4.0.0", @@ -58,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/declarations.d.ts b/packages/fdr-sdk/src/declarations.d.ts deleted file mode 100644 index 6951696c9b..0000000000 --- a/packages/fdr-sdk/src/declarations.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -declare module "core-js-pure/actual/structured-clone" { - const structuredClone: (value: T) => T; - - export default structuredClone; -} diff --git a/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts b/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts index 50095f823b..6d4e946fcf 100644 --- a/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts +++ b/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts @@ -1,4 +1,4 @@ -// import structuredClone from "core-js-pure/actual/structured-clone"; +import structuredClone from "@ungap/structured-clone"; import { DeepReadonly } from "ts-essentials"; import { FernNavigation } from "../.."; import { prunetree } from "../../utils/traversers/prunetree"; @@ -31,7 +31,8 @@ function mutablePruneNavigationTree( // after deletion, if the node no longer has any children, we can delete the parent node too // but only if the parent node is NOT a visitable page - shouldDeleteParent: (parent) => !hasChildren(parent) && !FernNavigation.isPage(parent), + shouldDeleteParent: (parent: FernNavigation.NavigationNodeParent) => + !hasChildren(parent) && !FernNavigation.isPage(parent), }); if (result == null) { 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/docs-bundle/src/middleware.ts b/packages/ui/docs-bundle/src/middleware.ts index af1df20990..175f57cd0c 100644 --- a/packages/ui/docs-bundle/src/middleware.ts +++ b/packages/ui/docs-bundle/src/middleware.ts @@ -104,7 +104,7 @@ export const middleware: NextMiddleware = async (request) => { if (!withBasicTokenViewAllowed(authConfig.allowlist, pathname)) { const destination = new URL(authConfig.redirect); destination.searchParams.set("state", urlJoin(`https://${xFernHost}`, pathname)); - return NextResponse.redirect(destination, { status: 302 }); + return NextResponse.redirect(destination); } } diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/callback.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/callback.ts index 2a5f14c548..c04ea71e2e 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/callback.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/auth/callback.ts @@ -44,7 +44,7 @@ export default async function GET(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 { 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 aebe2ce316..dcf869810d 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,7 +1,7 @@ import { DocsKVCache } from "@/server/DocsCache"; import { getAuthEdgeConfig } from "@/server/auth/getAuthEdgeConfig"; import { Revalidator } from "@/server/revalidator"; -import { withBasicTokenViewAllowed } from "@/server/withBasicTokenViewAllowed"; +import { pruneWithBasicTokenViewAllowed } from "@/server/withBasicTokenViewAllowed"; import { getXFernHostNode } from "@/server/xfernhost/node"; import { FdrAPI } from "@fern-api/fdr-sdk"; import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; @@ -34,9 +34,7 @@ const handler: NextApiHandler = async ( res: NextApiResponse, ): Promise => { const xFernHost = getXFernHostNode(req, true); - const authConfig = await getAuthEdgeConfig(xFernHost); - - let allowlist: string[] = []; + const auth = await getAuthEdgeConfig(xFernHost); /** * If the auth config is basic_token_verification, we don't need to revalidate. @@ -44,11 +42,9 @@ const handler: NextApiHandler = async ( * 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") { - if (authConfig.allowlist == null || authConfig.allowlist.length === 0) { + if (auth?.type === "basic_token_verification") { + if (auth.allowlist == null || auth.allowlist.length === 0) { return res.status(200).json({ successfulRevalidations: [], failedRevalidations: [] }); - } else { - allowlist = authConfig.allowlist; } } @@ -66,15 +62,16 @@ const handler: NextApiHandler = async ( .json({ successfulRevalidations: [], failedRevalidations: [] }); } - const node = FernNavigation.utils.toRootNode(docs.body); - const collector = NodeCollector.collect(node); - let slugs = collector.pageSlugs; + let node = FernNavigation.utils.toRootNode(docs.body); - // if the allowlist is nonempty, it means some of the routes are protected by basic_token_verification - if (allowlist.length > 0) { - slugs = slugs.filter((slug) => withBasicTokenViewAllowed(allowlist, `/${slug}`)); + // If the domain is basic_token_verification, we only want to include slugs that are allowed + if (auth?.type === "basic_token_verification") { + node = pruneWithBasicTokenViewAllowed(node, auth.allowlist); } + const collector = NodeCollector.collect(node); + const slugs = collector.pageSlugs; + const cache = DocsKVCache.getInstance(xFernHost); const previouslyVisitedSlugs = (await cache.getVisitedSlugs()).filter((slug) => !slugs.includes(slug)); diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/sitemap.xml.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/sitemap.xml.ts index 0fd827fea2..64993256c2 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/sitemap.xml.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/sitemap.xml.ts @@ -3,7 +3,7 @@ import { getAuthEdgeConfig } from "@/server/auth/getAuthEdgeConfig"; import { buildUrlFromApiEdge } from "@/server/buildUrlFromApi"; import { loadWithUrl } from "@/server/loadWithUrl"; import { conformTrailingSlash } from "@/server/trailingSlash"; -import { withBasicTokenViewAllowed } from "@/server/withBasicTokenViewAllowed"; +import { pruneWithBasicTokenViewAllowed } from "@/server/withBasicTokenViewAllowed"; import { getXFernHostEdge } from "@/server/xfernhost/edge"; import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; import { NodeCollector } from "@fern-api/fdr-sdk/navigation"; @@ -33,15 +33,16 @@ export default async function GET(req: NextRequest): Promise { return new NextResponse(null, { status: 404 }); } - const node = FernNavigation.utils.toRootNode(docs.body); - const collector = NodeCollector.collect(node); - let slugs = collector.indexablePageSlugs; + let node = FernNavigation.utils.toRootNode(docs.body); // If the domain is basic_token_verification, we only want to include slugs that are allowed if (auth?.type === "basic_token_verification") { - slugs = slugs.filter((slug) => withBasicTokenViewAllowed(auth.allowlist, `/${slug}`)); + node = pruneWithBasicTokenViewAllowed(node, auth.allowlist); } + const collector = NodeCollector.collect(node); + const slugs = collector.indexablePageSlugs; + const urls = slugs.map((slug) => conformTrailingSlash(urljoin(withDefaultProtocol(xFernHost), slug))); const sitemap = getSitemapXml(urls); diff --git a/packages/ui/docs-bundle/src/server/auth/checkViewerAllowed.ts b/packages/ui/docs-bundle/src/server/auth/checkViewerAllowed.ts index dbc38ae8cb..0f112e2ec2 100644 --- a/packages/ui/docs-bundle/src/server/auth/checkViewerAllowed.ts +++ b/packages/ui/docs-bundle/src/server/auth/checkViewerAllowed.ts @@ -8,34 +8,29 @@ import { verifyFernJWT } from "./FernJWT"; export async function checkViewerAllowedEdge(auth: AuthEdgeConfig | undefined, req: NextRequest): Promise { const fern_token = req.cookies.get("fern_token")?.value; - if (auth?.type === "basic_token_verification") { - if (withBasicTokenViewAllowed(auth.allowlist, req.nextUrl.pathname)) { - return 200; - } - - if (fern_token == null) { - return 401; - } else { - const verified = await verifyFernJWT(fern_token, auth.secret, auth.issuer); - if (!verified) { - return 403; - } - } - } - return 200; + return checkViewerAllowedPathname(auth, req.nextUrl.pathname, fern_token); } export async function checkViewerAllowedNode(auth: AuthEdgeConfig | undefined, req: NextApiRequest): Promise { const fern_token = req.cookies.fern_token; + try { + const pathname = new URL(req.url ?? "").pathname; + return checkViewerAllowedPathname(auth, pathname, fern_token); + } catch (e) { + // something went wrong with the URL parsing + captureException(e); + return 500; + } +} +export async function checkViewerAllowedPathname( + auth: AuthEdgeConfig | undefined, + pathname: string, + fern_token: string | undefined, +): Promise { if (auth?.type === "basic_token_verification") { - try { - if (req.url && withBasicTokenViewAllowed(auth.allowlist, new URL(req.url).pathname)) { - return 200; - } - } catch (e) { - // something went wrong with the URL parsing - captureException(e); + if (withBasicTokenViewAllowed(auth.allowlist, pathname)) { + return 200; } if (fern_token == null) { diff --git a/packages/ui/docs-bundle/src/server/withBasicTokenViewAllowed.ts b/packages/ui/docs-bundle/src/server/withBasicTokenViewAllowed.ts index 5e956ae75e..3865f87736 100644 --- a/packages/ui/docs-bundle/src/server/withBasicTokenViewAllowed.ts +++ b/packages/ui/docs-bundle/src/server/withBasicTokenViewAllowed.ts @@ -1,4 +1,6 @@ +import { RootNode, isPage, utils } from "@fern-api/fdr-sdk/navigation"; import { matchPath } from "@fern-ui/fern-docs-utils"; +import { captureMessage } from "@sentry/nextjs"; /** * @param config Basic token verification configuration @@ -12,3 +14,21 @@ export function withBasicTokenViewAllowed(allowlist: string[] = [], pathname: st } return false; } + +export function pruneWithBasicTokenViewAllowed(node: RootNode, allowlist: string[] | undefined): RootNode { + const result = utils.pruneNavigationTree(node, (node) => { + if (isPage(node)) { + return withBasicTokenViewAllowed(allowlist, `/${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 ba92b20ea7..8c9ca1f627 100644 --- a/packages/ui/docs-bundle/src/server/withInitialProps.ts +++ b/packages/ui/docs-bundle/src/server/withInitialProps.ts @@ -26,7 +26,7 @@ import { getCustomerAnalytics } from "./getCustomerAnalytics"; import { handleLoadDocsError } from "./handleLoadDocsError"; import type { LoadWithUrlResponse } from "./loadWithUrl"; import { isTrailingSlashEnabled } from "./trailingSlash"; -import { withBasicTokenViewAllowed } from "./withBasicTokenViewAllowed"; +import { pruneWithBasicTokenViewAllowed } from "./withBasicTokenViewAllowed"; interface WithInitialProps { docs: LoadWithUrlResponse; @@ -69,14 +69,8 @@ export async function withInitialProps({ // 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 != null && authConfig.type === "basic_token_verification" && auth == null) { - root = FernNavigation.utils.pruneNavigationTree(root, (node) => { - if (FernNavigation.isPage(node)) { - return withBasicTokenViewAllowed(authConfig.allowlist, `/${node.slug}`); - } - - return true; - }); + if (authConfig?.type === "basic_token_verification" && auth == null) { + root = pruneWithBasicTokenViewAllowed(root, authConfig.allowlist); } // this should not happen, but if it does, we should return a 404 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9a56ee13d..cca4b26f4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -817,9 +817,9 @@ importers: '@fern-ui/core-utils': specifier: workspace:* version: link:../commons/core-utils - core-js-pure: - specifier: ^3.38.1 - version: 3.38.1 + '@ungap/structured-clone': + specifier: ^1.2.0 + version: 1.2.0 dayjs: specifier: ^1.11.11 version: 1.11.11 @@ -884,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 @@ -7554,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==} @@ -9492,9 +9498,6 @@ packages: core-js-pure@3.37.0: resolution: {integrity: sha512-d3BrpyFr5eD4KcbRvQ3FTUx/KWmaDesr7+a3+1+P46IUnNoEt+oiLijPINZMEon7w9oGkIINWxrBAU9DEciwFQ==} - core-js-pure@3.38.1: - resolution: {integrity: sha512-BY8Etc1FZqdw1glX0XNOq2FDwfrg/VGqoZOZCdaL+UmdaqDwQwYXkMJT4t6In+zfEfOJDcM9T0KdbBeJg8KKCQ==} - core-js@3.37.0: resolution: {integrity: sha512-fu5vHevQ8ZG4og+LXug8ulUtVxjOcEYvifJr7L5Bfq9GOztVqsKd9/59hUk2ZSbCrS3BqUr3EpaYGIYzq7g3Ug==} @@ -24128,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': {} @@ -26983,8 +26988,6 @@ snapshots: core-js-pure@3.37.0: {} - core-js-pure@3.38.1: {} - core-js@3.37.0: {} core-util-is@1.0.3: {} From 9c69447c3ed8c6a3e5356120cd7a7ebf921e89b9 Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Fri, 4 Oct 2024 18:00:58 -0400 Subject: [PATCH 19/27] more performant implementation --- .../src/navigation/utils/deleteChild.ts | 42 ++++--- .../navigation/utils/pruneNavigationTree.ts | 4 +- .../traversers/__test__/prunetree.test.ts | 5 + .../fdr-sdk/src/utils/traversers/prunetree.ts | 113 +++++------------- 4 files changed, 63 insertions(+), 101 deletions(-) diff --git a/packages/fdr-sdk/src/navigation/utils/deleteChild.ts b/packages/fdr-sdk/src/navigation/utils/deleteChild.ts index 6aebaa9f42..78930c79ed 100644 --- a/packages/fdr-sdk/src/navigation/utils/deleteChild.ts +++ b/packages/fdr-sdk/src/navigation/utils/deleteChild.ts @@ -26,6 +26,11 @@ export function mutableDeleteChild( // 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); @@ -44,60 +49,65 @@ export function mutableDeleteChild( switch (parent.type) { case "apiPackage": parent.children = parent.children.filter((child) => child.id !== node.id); - return "deleted"; + break; case "apiReference": parent.children = parent.children.filter((child) => child.id !== node.id); parent.changelog = parent.changelog?.id === node.id ? undefined : parent.changelog; - return "deleted"; + break; case "changelog": parent.children = parent.children.filter((child) => child.id !== node.id); - return "deleted"; + break; case "changelogYear": parent.children = parent.children.filter((child) => child.id !== node.id); - return "deleted"; + break; case "changelogMonth": parent.children = parent.children.filter((child) => child.id !== node.id); - return "deleted"; + 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; - return "deleted"; + break; case "product": - return "should-delete-parent"; case "root": return "should-delete-parent"; case "unversioned": if (node.id === parent.landingPage?.id) { parent.landingPage = undefined; - return "deleted"; } - return "should-delete-parent"; + break; case "section": parent.children = parent.children.filter((child) => child.id !== node.id); - return "deleted"; + break; case "sidebarGroup": parent.children = parent.children.filter((child) => child.id !== node.id); - return "deleted"; + break; case "tab": return "should-delete-parent"; case "sidebarRoot": parent.children = parent.children.filter((child) => child.id !== node.id); - return "deleted"; + break; case "tabbed": parent.children = parent.children.filter((child) => child.id !== node.id); - return "deleted"; + break; case "version": if (node.id === parent.landingPage?.id) { parent.landingPage = undefined; - return "deleted"; } - return "should-delete-parent"; + break; case "versioned": parent.children = parent.children.filter((child) => child.id !== node.id); - return "deleted"; + 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/pruneNavigationTree.ts b/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts index 6d4e946fcf..2dea9530bf 100644 --- a/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts +++ b/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts @@ -31,8 +31,8 @@ function mutablePruneNavigationTree( // 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), + // shouldDeleteParent: (parent: FernNavigation.NavigationNodeParent) => + // !hasChildren(parent) && !FernNavigation.isPage(parent), }); if (result == null) { diff --git a/packages/fdr-sdk/src/utils/traversers/__test__/prunetree.test.ts b/packages/fdr-sdk/src/utils/traversers/__test__/prunetree.test.ts index fbf5f020ca..e52eb27a87 100644 --- a/packages/fdr-sdk/src/utils/traversers/__test__/prunetree.test.ts +++ b/packages/fdr-sdk/src/utils/traversers/__test__/prunetree.test.ts @@ -7,6 +7,11 @@ const DELETER = (parent: Record | undefined, child: Record): DeleterAction => { return "deleted"; } parent.children = parent.children.filter((c) => c.id !== child.id); + + if (parent.children.length === 0) { + return "should-delete-parent"; + } + return "deleted"; }; diff --git a/packages/fdr-sdk/src/utils/traversers/prunetree.ts b/packages/fdr-sdk/src/utils/traversers/prunetree.ts index 122d20ec5d..d34aad46dc 100644 --- a/packages/fdr-sdk/src/utils/traversers/prunetree.ts +++ b/packages/fdr-sdk/src/utils/traversers/prunetree.ts @@ -1,4 +1,3 @@ -import { UnreachableCaseError } from "ts-essentials"; import { bfs } from "./bfs"; import { DeleterAction } from "./types"; @@ -17,16 +16,6 @@ interface PruneTreeOptions { */ deleter: (parent: PARENT | undefined, child: NODE) => DeleterAction; - /** - * After the child is deleted, we can check if the parent should be deleted too, - * e.g. if the parent has no children left. - * - * @param parent node - * @returns **true** if the node should be deleted - * @default parent => getChildren(parent).length === 0 - */ - shouldDeleteParent?: (parent: PARENT) => boolean; - /** * If there are circular references, we can use this function to get a unique identifier for the node. * @@ -37,48 +26,52 @@ interface PruneTreeOptions { getPointer?: (node: NODE) => POINTER; } -// TODO: this algorithm is not optimal, as it traverses the tree twice, and should be refactored to traverse only once -// it would be more efficient to BFS the tree once, collect all the nodes in an array, and reverse the array to delete the nodes from the bottom up export function prunetree( root: ROOT, opts: PruneTreeOptions, ): [result: ROOT | undefined, deleted: ReadonlySet] { - const { - predicate, - getChildren, - deleter, - shouldDeleteParent = (parent) => getChildren(parent).length === 0, - getPointer = (node) => node as unknown as POINTER, - } = opts; + const { predicate, getChildren, deleter, getPointer = (node) => node as unknown as POINTER } = opts; const deleted = new Set(); - const visitor = (node: NODE, parents: readonly PARENT[]) => { - // if the node or its parents was already deleted, we don't need to traverse it - if ([...parents, node].some((parent) => deleted.has(getPointer(parent)))) { - return "skip"; + 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(); - deleteChildAndMaybeParent(node, parents, { - deleter, - shouldDeleteParent, - getPointer, - }).forEach((id) => { - deleted.add(id); - }); + let action = deleter(parent, node); + let toDelete = node; - // since the node was deleted, its children are deleted too - // we don't need to traverse them, nor do we need to keep them in the tree. - // note: the deleted set will NOT contain the children of this node - return "skip"; - }; + while (action === "should-delete-parent" && parent != null) { + deleted.add(getPointer(toDelete)); + toDelete = parent; + action = deleter(ancestors.pop(), parent); + } - bfs(root, visitor, getChildren); + if (action === "deleted") { + deleted.add(getPointer(toDelete)); + } + }); if (deleted.has(getPointer(root))) { return [undefined, deleted]; @@ -86,49 +79,3 @@ export function prunetree { - deleter: (parent: PARENT | undefined, child: NODE) => DeleterAction; - shouldDeleteParent: (parent: PARENT) => boolean; - getPointer: (node: NODE) => POINTER; -} - -function deleteChildAndMaybeParent( - node: NODE, - parents: readonly PARENT[], - opts: DeleteChildOptions, -): POINTER[] { - const { deleter, shouldDeleteParent, getPointer } = opts; - - const ancestors = [...parents]; - const parent = ancestors.pop(); - - const result = deleter(parent, node); - - // if the node was only updated, don't mark it as deleted - if (result === "noop") { - return []; - } - - // if no parent exists, then the node is the root - else if (parent == null) { - return [getPointer(node)]; - } - - // if the node was not deletable, then we need to delete the parent too - else if (result === "should-delete-parent") { - return [getPointer(node), ...deleteChildAndMaybeParent(parent, ancestors, opts)]; - } - - // traverse up the tree and delete the parent if necessary - else if (result === "deleted") { - if (shouldDeleteParent(parent)) { - return [getPointer(node), ...deleteChildAndMaybeParent(parent, ancestors, opts)]; - } else { - return [getPointer(node)]; - } - } - - // type safety check - throw new UnreachableCaseError(result); -} From 23567f402a89732562eaec72744f07cb00175192 Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Fri, 4 Oct 2024 18:13:49 -0400 Subject: [PATCH 20/27] denylist and allowlist --- packages/ui/app/src/auth/types.ts | 7 +++- packages/ui/docs-bundle/src/middleware.ts | 4 +-- .../pages/api/fern-docs/revalidate-all/v3.ts | 19 ++--------- .../pages/api/fern-docs/revalidate-all/v4.ts | 34 +++++-------------- .../src/pages/api/fern-docs/sitemap.xml.ts | 4 +-- .../withBasicTokenViewAllowed.test.ts | 22 +++++++----- .../src/server/auth/checkViewerAllowed.ts | 4 +-- ...ViewAllowed.ts => withBasicTokenPublic.ts} | 21 +++++++++--- .../src/server/withInitialProps.ts | 4 +-- 9 files changed, 55 insertions(+), 64 deletions(-) rename packages/ui/docs-bundle/src/server/{withBasicTokenViewAllowed.ts => withBasicTokenPublic.ts} (51%) diff --git a/packages/ui/app/src/auth/types.ts b/packages/ui/app/src/auth/types.ts index 00ba27e944..40c80a1b6c 100644 --- a/packages/ui/app/src/auth/types.ts +++ b/packages/ui/app/src/auth/types.ts @@ -37,7 +37,12 @@ export const AuthEdgeConfigBasicTokenVerificationSchema = z.object({ allowlist: z .array(z.string(), { - description: "List of pages that are public and do not require authentication", + 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(), }); diff --git a/packages/ui/docs-bundle/src/middleware.ts b/packages/ui/docs-bundle/src/middleware.ts index 175f57cd0c..7ab682ba25 100644 --- a/packages/ui/docs-bundle/src/middleware.ts +++ b/packages/ui/docs-bundle/src/middleware.ts @@ -8,7 +8,7 @@ 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 { withBasicTokenViewAllowed } from "./server/withBasicTokenViewAllowed"; +import { withBasicTokenPublic } from "./server/withBasicTokenPublic"; const API_FERN_DOCS_PATTERN = /^(?!\/api\/fern-docs\/).*(\/api\/fern-docs\/)/; const CHANGELOG_PATTERN = /\.(rss|atom)$/; @@ -101,7 +101,7 @@ export const middleware: NextMiddleware = async (request) => { * redirect to the custom auth provider */ if (!isLoggedIn && authConfig?.type === "basic_token_verification") { - if (!withBasicTokenViewAllowed(authConfig.allowlist, pathname)) { + 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/revalidate-all/v3.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/revalidate-all/v3.ts index dcf869810d..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,7 +1,7 @@ import { DocsKVCache } from "@/server/DocsCache"; import { getAuthEdgeConfig } from "@/server/auth/getAuthEdgeConfig"; import { Revalidator } from "@/server/revalidator"; -import { pruneWithBasicTokenViewAllowed } from "@/server/withBasicTokenViewAllowed"; +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"; @@ -34,19 +34,6 @@ const handler: NextApiHandler = async ( res: NextApiResponse, ): Promise => { const xFernHost = getXFernHostNode(req, true); - const auth = 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 (auth?.type === "basic_token_verification") { - if (auth.allowlist == null || auth.allowlist.length === 0) { - return res.status(200).json({ successfulRevalidations: [], failedRevalidations: [] }); - } - } const revalidate = new Revalidator(res, xFernHost); @@ -64,9 +51,9 @@ const handler: NextApiHandler = async ( let node = FernNavigation.utils.toRootNode(docs.body); - // If the domain is basic_token_verification, we only want to include slugs that are allowed + const auth = await getAuthEdgeConfig(xFernHost); if (auth?.type === "basic_token_verification") { - node = pruneWithBasicTokenViewAllowed(node, auth.allowlist); + node = pruneWithBasicTokenPublic(auth, node); } const collector = NodeCollector.collect(node); 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 1a0e8c1d63..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,7 +1,7 @@ import { DocsKVCache } from "@/server/DocsCache"; import { getAuthEdgeConfig } from "@/server/auth/getAuthEdgeConfig"; import { Revalidator } from "@/server/revalidator"; -import { withBasicTokenViewAllowed } from "@/server/withBasicTokenViewAllowed"; +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"; @@ -22,7 +22,6 @@ const handler: NextApiHandler = async ( res: NextApiResponse, ): Promise => { const xFernHost = getXFernHostNode(req, true); - const authConfig = await getAuthEdgeConfig(xFernHost); /** * Limit the number of paths to revalidate to max of 100. @@ -45,22 +44,6 @@ const handler: NextApiHandler = async ( return res.status(400).json({ total: 0, results: [] }); } - let allowlist: string[] = []; - - /** - * 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") { - if (authConfig.allowlist == null || authConfig.allowlist.length === 0) { - return res.status(200).json({ total: 0, results: [] }); - } else { - allowlist = authConfig.allowlist; - } - } - 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); @@ -84,12 +73,7 @@ const handler: NextApiHandler = async ( const total = slugs.length; const start = offset * limit; - let batch = slugs.slice(start, start + limit); - - // if the allowlist is nonempty, it means some of the routes are protected by basic_token_verification - if (allowlist.length > 0) { - batch = batch.filter((slug) => withBasicTokenViewAllowed(allowlist, `/${slug}`)); - } + const batch = slugs.slice(start, start + limit); const results = await revalidate.batch(batch); diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/sitemap.xml.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/sitemap.xml.ts index 64993256c2..13aaedc110 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/sitemap.xml.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/sitemap.xml.ts @@ -3,7 +3,7 @@ import { getAuthEdgeConfig } from "@/server/auth/getAuthEdgeConfig"; import { buildUrlFromApiEdge } from "@/server/buildUrlFromApi"; import { loadWithUrl } from "@/server/loadWithUrl"; import { conformTrailingSlash } from "@/server/trailingSlash"; -import { pruneWithBasicTokenViewAllowed } from "@/server/withBasicTokenViewAllowed"; +import { pruneWithBasicTokenPublic } from "@/server/withBasicTokenPublic"; import { getXFernHostEdge } from "@/server/xfernhost/edge"; import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; import { NodeCollector } from "@fern-api/fdr-sdk/navigation"; @@ -37,7 +37,7 @@ export default async function GET(req: NextRequest): Promise { // If the domain is basic_token_verification, we only want to include slugs that are allowed if (auth?.type === "basic_token_verification") { - node = pruneWithBasicTokenViewAllowed(node, auth.allowlist); + node = pruneWithBasicTokenPublic(auth, node); } const collector = NodeCollector.collect(node); 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 267e164b7a..b555406d9e 100644 --- a/packages/ui/docs-bundle/src/server/__test__/withBasicTokenViewAllowed.test.ts +++ b/packages/ui/docs-bundle/src/server/__test__/withBasicTokenViewAllowed.test.ts @@ -1,25 +1,29 @@ -import { withBasicTokenViewAllowed } from "../withBasicTokenViewAllowed"; +import { withBasicTokenPublic } from "../withBasicTokenPublic"; -describe("withBasicTokenViewAllowed", () => { +describe("withBasicTokenPublic", () => { it("should deny the request if the allowlist is empty", () => { - expect(withBasicTokenViewAllowed(undefined, "/public")).toBe(false); - expect(withBasicTokenViewAllowed([], "/public")).toBe(false); + 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(withBasicTokenViewAllowed(["/public"], "/public")).toBe(true); + 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(withBasicTokenViewAllowed(["/public/(.*)"], "/public/123")).toBe(true); + 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(withBasicTokenViewAllowed(["/public/:id"], "/public/123")).toBe(true); + 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(withBasicTokenViewAllowed(["/public", "/public/:id"], "/private")).toBe(false); - expect(withBasicTokenViewAllowed(["/public", "/public/:id"], "/private/123")).toBe(false); + 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/docs-bundle/src/server/auth/checkViewerAllowed.ts b/packages/ui/docs-bundle/src/server/auth/checkViewerAllowed.ts index 0f112e2ec2..fa6d423ded 100644 --- a/packages/ui/docs-bundle/src/server/auth/checkViewerAllowed.ts +++ b/packages/ui/docs-bundle/src/server/auth/checkViewerAllowed.ts @@ -2,7 +2,7 @@ import { AuthEdgeConfig } from "@fern-ui/ui/auth"; import { captureException } from "@sentry/nextjs"; import type { NextApiRequest } from "next"; import type { NextRequest } from "next/server"; -import { withBasicTokenViewAllowed } from "../withBasicTokenViewAllowed"; +import { withBasicTokenPublic } from "../withBasicTokenPublic"; import { verifyFernJWT } from "./FernJWT"; export async function checkViewerAllowedEdge(auth: AuthEdgeConfig | undefined, req: NextRequest): Promise { @@ -29,7 +29,7 @@ export async function checkViewerAllowedPathname( fern_token: string | undefined, ): Promise { if (auth?.type === "basic_token_verification") { - if (withBasicTokenViewAllowed(auth.allowlist, pathname)) { + if (withBasicTokenPublic(auth, pathname)) { return 200; } diff --git a/packages/ui/docs-bundle/src/server/withBasicTokenViewAllowed.ts b/packages/ui/docs-bundle/src/server/withBasicTokenPublic.ts similarity index 51% rename from packages/ui/docs-bundle/src/server/withBasicTokenViewAllowed.ts rename to packages/ui/docs-bundle/src/server/withBasicTokenPublic.ts index 3865f87736..68b3d616a6 100644 --- a/packages/ui/docs-bundle/src/server/withBasicTokenViewAllowed.ts +++ b/packages/ui/docs-bundle/src/server/withBasicTokenPublic.ts @@ -1,24 +1,35 @@ 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 config Basic token verification configuration + * @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 withBasicTokenViewAllowed(allowlist: string[] = [], pathname: string): boolean { +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 (allowlist.find((path) => matchPath(path, pathname))) { + 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 pruneWithBasicTokenViewAllowed(node: RootNode, allowlist: string[] | undefined): RootNode { +export function pruneWithBasicTokenPublic(auth: AuthEdgeConfigBasicTokenVerification, node: RootNode): RootNode { const result = utils.pruneNavigationTree(node, (node) => { if (isPage(node)) { - return withBasicTokenViewAllowed(allowlist, `/${node.slug}`); + return withBasicTokenPublic(auth, `/${node.slug}`); } return true; diff --git a/packages/ui/docs-bundle/src/server/withInitialProps.ts b/packages/ui/docs-bundle/src/server/withInitialProps.ts index 8c9ca1f627..9a87661e89 100644 --- a/packages/ui/docs-bundle/src/server/withInitialProps.ts +++ b/packages/ui/docs-bundle/src/server/withInitialProps.ts @@ -26,7 +26,7 @@ import { getCustomerAnalytics } from "./getCustomerAnalytics"; import { handleLoadDocsError } from "./handleLoadDocsError"; import type { LoadWithUrlResponse } from "./loadWithUrl"; import { isTrailingSlashEnabled } from "./trailingSlash"; -import { pruneWithBasicTokenViewAllowed } from "./withBasicTokenViewAllowed"; +import { pruneWithBasicTokenPublic } from "./withBasicTokenPublic"; interface WithInitialProps { docs: LoadWithUrlResponse; @@ -70,7 +70,7 @@ export async function withInitialProps({ // 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 = pruneWithBasicTokenViewAllowed(root, authConfig.allowlist); + root = pruneWithBasicTokenPublic(authConfig, root); } // this should not happen, but if it does, we should return a 404 From 33fd8a28e9ea6470468b00a762f60b05d17850f1 Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Fri, 4 Oct 2024 18:20:45 -0400 Subject: [PATCH 21/27] update notfound logic --- .../ui/docs-bundle/src/server/withInitialProps.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/ui/docs-bundle/src/server/withInitialProps.ts b/packages/ui/docs-bundle/src/server/withInitialProps.ts index 9a87661e89..dbb3260a49 100644 --- a/packages/ui/docs-bundle/src/server/withInitialProps.ts +++ b/packages/ui/docs-bundle/src/server/withInitialProps.ts @@ -58,11 +58,13 @@ export async function withInitialProps({ } const featureFlags = await getFeatureFlags(xFernHost); - let root: FernNavigation.RootNode | undefined = 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); @@ -91,6 +93,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. From ca25bfcc48d71a59f6d8b29bea81685ed647b887 Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Fri, 4 Oct 2024 18:29:03 -0400 Subject: [PATCH 22/27] add login/logout button --- .../src/server/withInitialProps.ts | 41 ++++++++++++++++--- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/ui/docs-bundle/src/server/withInitialProps.ts b/packages/ui/docs-bundle/src/server/withInitialProps.ts index dbb3260a49..5d807c9c32 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"; @@ -147,6 +148,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" @@ -183,6 +189,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, @@ -190,7 +224,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, @@ -264,11 +298,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, From 82221667196f98b4df039eba198cb0cbaa5ee23c Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Mon, 7 Oct 2024 10:21:21 -0400 Subject: [PATCH 23/27] undo auth on sitemap --- .../[api]/endpoint/[endpoint].ts | 11 +-- .../api-definition/[api]/webhook/[webhook].ts | 11 +-- .../[api]/websocket/[websocket].ts | 11 +-- .../src/pages/api/fern-docs/changelog.ts | 33 ++++---- .../src/pages/api/fern-docs/resolve-api.ts | 13 +--- .../src/pages/api/fern-docs/sitemap.xml.ts | 35 ++------- .../ui/docs-bundle/src/server/DocsLoader.ts | 75 +++++++++++++++++++ .../src/server/auth/checkViewerAllowed.ts | 14 ---- 8 files changed, 106 insertions(+), 97 deletions(-) create mode 100644 packages/ui/docs-bundle/src/server/DocsLoader.ts 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 b708f44e37..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,6 +1,4 @@ import { ApiDefinitionLoader } from "@/server/ApiDefinitionLoader"; -import { checkViewerAllowedNode } from "@/server/auth/checkViewerAllowed"; -import { getAuthEdgeConfig } from "@/server/auth/getAuthEdgeConfig"; import { getXFernHostNode } from "@/server/xfernhost/node"; import * as ApiDefinition from "@fern-api/fdr-sdk/api-definition"; import { NextApiHandler, NextApiResponse } from "next"; @@ -14,14 +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 14d69445c7..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,6 +1,4 @@ import { ApiDefinitionLoader } from "@/server/ApiDefinitionLoader"; -import { checkViewerAllowedNode } from "@/server/auth/checkViewerAllowed"; -import { getAuthEdgeConfig } from "@/server/auth/getAuthEdgeConfig"; import { getXFernHostNode } from "@/server/xfernhost/node"; import * as ApiDefinition from "@fern-api/fdr-sdk/api-definition"; import { NextApiHandler, NextApiResponse } from "next"; @@ -14,14 +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 aae5e936df..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,6 +1,4 @@ import { ApiDefinitionLoader } from "@/server/ApiDefinitionLoader"; -import { checkViewerAllowedNode } from "@/server/auth/checkViewerAllowed"; -import { getAuthEdgeConfig } from "@/server/auth/getAuthEdgeConfig"; import { getXFernHostNode } from "@/server/xfernhost/node"; import * as ApiDefinition from "@fern-api/fdr-sdk/api-definition"; import { NextApiHandler, NextApiResponse } from "next"; @@ -14,14 +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/changelog.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/changelog.ts index 707d9e0a15..f0962bb068 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,7 +1,4 @@ -import { checkViewerAllowedNode } from "@/server/auth/checkViewerAllowed"; -import { getAuthEdgeConfig } from "@/server/auth/getAuthEdgeConfig"; -import { buildUrlFromApiNode } from "@/server/buildUrlFromApi"; -import { loadWithUrl } from "@/server/loadWithUrl"; +import { DocsLoader } from "@/server/DocsLoader"; 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"; @@ -27,24 +24,15 @@ export default async function responseApiHandler(req: NextApiRequest, res: NextA } const xFernHost = getXFernHostNode(req); - const auth = await getAuthEdgeConfig(xFernHost); - const status = await checkViewerAllowedNode(auth, req); - if (status >= 400) { - return res.status(status).end(); - } - - const headers = new Headers(); - headers.set("x-fern-host", xFernHost); + const loader = DocsLoader.for(xFernHost); - 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)); @@ -64,11 +52,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); @@ -78,6 +69,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()); @@ -94,7 +87,7 @@ function toFeedItem( entry: FernNavigation.ChangelogEntryNode, xFernHost: string, pages: Record, - files: Record, + files: Record, ): Item { const item: Item = { title: entry.title, @@ -135,7 +128,7 @@ function toFeedItem( function toUrl( idOrUrl: DocsV1Read.FileIdOrUrl | undefined, - files: Record, + files: Record, ): string | undefined { if (idOrUrl == null) { return undefined; @@ -143,7 +136,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/resolve-api.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/resolve-api.ts index dcaf87b44b..d07afa6915 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 @@ -1,6 +1,4 @@ import { DocsKVCache } from "@/server/DocsCache"; -import { checkViewerAllowedNode } from "@/server/auth/checkViewerAllowed"; -import { getAuthEdgeConfig } from "@/server/auth/getAuthEdgeConfig"; import { buildUrlFromApiNode } from "@/server/buildUrlFromApi"; import { getXFernHostNode } from "@/server/xfernhost/node"; import { FdrAPI } from "@fern-api/fdr-sdk"; @@ -15,6 +13,8 @@ export const dynamic = "force-dynamic"; /** * This is now deprecated. use /api/fern-docs/api-definition/{apiDefinitionId}/endpoint/{endpointId} instead. + * + * @deprecated */ const resolveApiHandler: NextApiHandler = async ( req, @@ -27,19 +27,14 @@ const resolveApiHandler: NextApiHandler = async ( } const xFernHost = getXFernHostNode(req); - const auth = await getAuthEdgeConfig(xFernHost); - - const status = await checkViewerAllowedNode(auth, 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/sitemap.xml.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/sitemap.xml.ts index 13aaedc110..1513ae0b68 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/sitemap.xml.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/sitemap.xml.ts @@ -1,11 +1,6 @@ -import { checkViewerAllowedEdge } from "@/server/auth/checkViewerAllowed"; -import { getAuthEdgeConfig } from "@/server/auth/getAuthEdgeConfig"; -import { buildUrlFromApiEdge } from "@/server/buildUrlFromApi"; -import { loadWithUrl } from "@/server/loadWithUrl"; +import { DocsLoader } from "@/server/DocsLoader"; import { conformTrailingSlash } from "@/server/trailingSlash"; -import { pruneWithBasicTokenPublic } from "@/server/withBasicTokenPublic"; import { getXFernHostEdge } from "@/server/xfernhost/edge"; -import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; import { NodeCollector } from "@fern-api/fdr-sdk/navigation"; import { withDefaultProtocol } from "@fern-ui/core-utils"; import { NextRequest, NextResponse } from "next/server"; @@ -19,31 +14,17 @@ export default async function GET(req: NextRequest): Promise { return new NextResponse(null, { status: 405 }); } const xFernHost = getXFernHostEdge(req); - const auth = await getAuthEdgeConfig(xFernHost); - const status = await checkViewerAllowedEdge(auth, req); - if (status >= 400) { - return NextResponse.next({ status }); - } - - const url = buildUrlFromApiEdge(xFernHost, req); - const docs = await loadWithUrl(url); - - if (!docs.ok) { - return new NextResponse(null, { status: 404 }); - } + // load the root node + const root = await DocsLoader.for(xFernHost).root(); - let node = FernNavigation.utils.toRootNode(docs.body); - - // If the domain is basic_token_verification, we only want to include slugs that are allowed - if (auth?.type === "basic_token_verification") { - node = pruneWithBasicTokenPublic(auth, node); - } - - const collector = NodeCollector.collect(node); - const slugs = collector.indexablePageSlugs; + // collect all indexable page slugs + const slugs = NodeCollector.collect(root).indexablePageSlugs; + // convert slugs to full urls const urls = slugs.map((slug) => conformTrailingSlash(urljoin(withDefaultProtocol(xFernHost), slug))); + + // generate sitemap xml const sitemap = getSitemapXml(urls); const headers = new 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..c60632f897 --- /dev/null +++ b/packages/ui/docs-bundle/src/server/DocsLoader.ts @@ -0,0 +1,75 @@ +import type { DocsV1Read, DocsV2Read } from "@fern-api/fdr-sdk"; +import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; +import type { AuthEdgeConfig } from "@fern-ui/ui/auth"; +import { getAuthEdgeConfig } from "./auth/getAuthEdgeConfig"; +import { loadWithUrl } from "./loadWithUrl"; +import { pruneWithBasicTokenPublic } from "./withBasicTokenPublic"; + +export class DocsLoader { + static for(xFernHost: string): DocsLoader { + return new DocsLoader(xFernHost); + } + + private constructor(private xFernHost: string) {} + + private auth: AuthEdgeConfig | undefined; + public withAuth(auth: AuthEdgeConfig): DocsLoader { + this.auth = auth; + return this; + } + + private async loadAuth(): Promise { + if (!this.auth) { + this.auth = await getAuthEdgeConfig(this.xFernHost); + } + return this.auth; + } + + private loadForDocsUrlResponse: DocsV2Read.LoadDocsForUrlResponse | undefined; + public withLoadDocsForUrlResponse(loadForDocsUrlResponse: DocsV2Read.LoadDocsForUrlResponse): DocsLoader { + this.loadForDocsUrlResponse = loadForDocsUrlResponse; + return this; + } + private async loadDocs(): Promise { + if (!this.loadForDocsUrlResponse) { + const response = await loadWithUrl(this.xFernHost); + if (response.ok) { + this.loadForDocsUrlResponse = response.body; + } + } + return this.loadForDocsUrlResponse; + } + + public async root(): Promise { + const auth = 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 (auth?.type === "basic_token_verification") { + try { + // TODO: store this in cache + node = pruneWithBasicTokenPublic(auth, node); + } catch (e) { + return undefined; + } + } + + return node; + } + + 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/auth/checkViewerAllowed.ts b/packages/ui/docs-bundle/src/server/auth/checkViewerAllowed.ts index fa6d423ded..a04bed1a98 100644 --- a/packages/ui/docs-bundle/src/server/auth/checkViewerAllowed.ts +++ b/packages/ui/docs-bundle/src/server/auth/checkViewerAllowed.ts @@ -1,6 +1,4 @@ import { AuthEdgeConfig } from "@fern-ui/ui/auth"; -import { captureException } from "@sentry/nextjs"; -import type { NextApiRequest } from "next"; import type { NextRequest } from "next/server"; import { withBasicTokenPublic } from "../withBasicTokenPublic"; import { verifyFernJWT } from "./FernJWT"; @@ -11,18 +9,6 @@ export async function checkViewerAllowedEdge(auth: AuthEdgeConfig | undefined, r return checkViewerAllowedPathname(auth, req.nextUrl.pathname, fern_token); } -export async function checkViewerAllowedNode(auth: AuthEdgeConfig | undefined, req: NextApiRequest): Promise { - const fern_token = req.cookies.fern_token; - try { - const pathname = new URL(req.url ?? "").pathname; - return checkViewerAllowedPathname(auth, pathname, fern_token); - } catch (e) { - // something went wrong with the URL parsing - captureException(e); - return 500; - } -} - export async function checkViewerAllowedPathname( auth: AuthEdgeConfig | undefined, pathname: string, From caaecf349f790e60b7879edf29625ba286754b22 Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Mon, 7 Oct 2024 10:30:03 -0400 Subject: [PATCH 24/27] pass --- packages/ui/docs-bundle/src/pages/api/fern-docs/resolve-api.ts | 2 -- 1 file changed, 2 deletions(-) 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 d07afa6915..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 @@ -13,8 +13,6 @@ export const dynamic = "force-dynamic"; /** * This is now deprecated. use /api/fern-docs/api-definition/{apiDefinitionId}/endpoint/{endpointId} instead. - * - * @deprecated */ const resolveApiHandler: NextApiHandler = async ( req, From 3a8b00273c674e3150f9c94e84703790516bf243 Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Mon, 7 Oct 2024 10:42:47 -0400 Subject: [PATCH 25/27] update auth logic --- .../src/pages/api/fern-docs/changelog.ts | 2 +- .../src/pages/api/fern-docs/sitemap.xml.ts | 3 +- .../ui/docs-bundle/src/server/DocsLoader.ts | 33 ++++++++++++++++--- .../ui/docs-bundle/src/server/authProps.ts | 11 +++---- .../src/server/getDocsPageProps.ts | 6 +++- .../src/server/getDynamicDocsPageProps.ts | 4 +-- .../src/server/withInitialProps.ts | 5 ++- 7 files changed, 46 insertions(+), 18 deletions(-) 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 f0962bb068..c1cdea72d8 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 @@ -25,7 +25,7 @@ export default async function responseApiHandler(req: NextApiRequest, res: NextA const xFernHost = getXFernHostNode(req); - const loader = DocsLoader.for(xFernHost); + const loader = DocsLoader.for(xFernHost, req.cookies.fern_token); const root = await loader.root(); diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/sitemap.xml.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/sitemap.xml.ts index 1513ae0b68..39e1f77000 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/sitemap.xml.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/sitemap.xml.ts @@ -16,7 +16,8 @@ export default async function GET(req: NextRequest): Promise { const xFernHost = getXFernHostEdge(req); // load the root node - const root = await DocsLoader.for(xFernHost).root(); + const fern_token = req.cookies.get("fern_token")?.value; + const root = await DocsLoader.for(xFernHost, fern_token).root(); // collect all indexable page slugs const slugs = NodeCollector.collect(root).indexablePageSlugs; diff --git a/packages/ui/docs-bundle/src/server/DocsLoader.ts b/packages/ui/docs-bundle/src/server/DocsLoader.ts index c60632f897..da56004433 100644 --- a/packages/ui/docs-bundle/src/server/DocsLoader.ts +++ b/packages/ui/docs-bundle/src/server/DocsLoader.ts @@ -1,17 +1,23 @@ import type { DocsV1Read, DocsV2Read } from "@fern-api/fdr-sdk"; import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; import type { AuthEdgeConfig } 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): DocsLoader { - return new DocsLoader(xFernHost); + static for(xFernHost: string, fern_token: string | undefined): DocsLoader { + return new DocsLoader(xFernHost, fern_token); } - private constructor(private xFernHost: string) {} + private constructor( + private xFernHost: string, + private fern_token: string | undefined, + ) {} + private authenticated: boolean = false; private auth: AuthEdgeConfig | undefined; public withAuth(auth: AuthEdgeConfig): DocsLoader { this.auth = auth; @@ -32,7 +38,24 @@ export class DocsLoader { } private async loadDocs(): Promise { if (!this.loadForDocsUrlResponse) { - const response = await loadWithUrl(this.xFernHost); + const auth = await this.loadAuth(); + let authProps: AuthProps | undefined; + + try { + if (this.fern_token) { + const user = await verifyFernJWTConfig(this.fern_token, auth); + this.authenticated = true; + authProps = { + user, + token: this.fern_token, + }; + } + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } + + const response = await loadWithUrl(this.xFernHost, authProps); if (response.ok) { this.loadForDocsUrlResponse = response.body; } @@ -51,7 +74,7 @@ export class DocsLoader { let node = FernNavigation.utils.toRootNode(docs); // If the domain is basic_token_verification, we only want to include slugs that are allowed - if (auth?.type === "basic_token_verification") { + if (auth?.type === "basic_token_verification" && !this.authenticated) { try { // TODO: store this in cache node = pruneWithBasicTokenPublic(auth, node); diff --git a/packages/ui/docs-bundle/src/server/authProps.ts b/packages/ui/docs-bundle/src/server/authProps.ts index fe89c1c9e2..e868336bf3 100644 --- a/packages/ui/docs-bundle/src/server/authProps.ts +++ b/packages/ui/docs-bundle/src/server/authProps.ts @@ -1,12 +1,10 @@ import type { FernUser } from "@fern-ui/ui/auth"; -import type { NextApiRequestCookies } from "next/dist/server/api-utils"; import { verifyFernJWTConfig } from "./auth/FernJWT"; import { getAuthEdgeConfig } from "./auth/getAuthEdgeConfig"; export interface AuthProps { token: string; user: FernUser; - cookies: NextApiRequestCookies; } /** @@ -16,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, fern_token: string | null | undefined): Promise { + if (fern_token == 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(fern_token, config); + const token = withPrefix(fern_token, user.partner); const authProps: AuthProps = { token, user, - cookies, }; return authProps; diff --git a/packages/ui/docs-bundle/src/server/getDocsPageProps.ts b/packages/ui/docs-bundle/src/server/getDocsPageProps.ts index 83261c3d01..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 { 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..c09afbc885 100644 --- a/packages/ui/docs-bundle/src/server/getDynamicDocsPageProps.ts +++ b/packages/ui/docs-bundle/src/server/getDynamicDocsPageProps.ts @@ -24,6 +24,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.fern_token); + return getDocsPageProps(xFernHost, slug, authProps, cookies); } diff --git a/packages/ui/docs-bundle/src/server/withInitialProps.ts b/packages/ui/docs-bundle/src/server/withInitialProps.ts index 05d13a0306..66af07486a 100644 --- a/packages/ui/docs-bundle/src/server/withInitialProps.ts +++ b/packages/ui/docs-bundle/src/server/withInitialProps.ts @@ -17,6 +17,7 @@ import { } from "@fern-ui/ui"; 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"; @@ -35,6 +36,7 @@ interface WithInitialProps { slug: string[]; xFernHost: string; auth?: AuthProps; + cookies?: NextApiRequestCookies; } export async function withInitialProps({ @@ -42,6 +44,7 @@ export async function withInitialProps({ slug: slugArray, xFernHost, auth, + cookies, }: WithInitialProps): Promise>> { if (!docsResponse.ok) { return handleLoadDocsError(xFernHost, slugArray, docsResponse.error); @@ -307,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 { From 1ed83cf68667ede629733692ab072b53c0790446 Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Mon, 7 Oct 2024 11:08:53 -0400 Subject: [PATCH 26/27] use constants --- packages/ui/docs-bundle/src/middleware.ts | 7 ++- .../api/fern-docs/auth/api-key-injection.ts | 7 ++- .../src/pages/api/fern-docs/auth/callback.ts | 3 +- .../pages/api/fern-docs/auth/jwt/callback.ts | 6 +- .../src/pages/api/fern-docs/auth/logout.ts | 7 ++- .../src/pages/api/fern-docs/changelog.ts | 4 +- .../pages/api/fern-docs/oauth/ory/callback.ts | 9 +-- .../src/pages/api/fern-docs/preview.ts | 7 ++- .../src/pages/api/fern-docs/sitemap.xml.ts | 5 +- .../ui/docs-bundle/src/server/DocsLoader.ts | 61 +++++++++++-------- .../src/server/auth/checkViewerAllowed.ts | 11 ++-- .../docs-bundle/src/server/auth/withSecure.ts | 3 + .../ui/docs-bundle/src/server/authProps.ts | 8 +-- .../ui/docs-bundle/src/server/constants.ts | 2 + .../src/server/getDynamicDocsPageProps.ts | 5 +- 15 files changed, 85 insertions(+), 60 deletions(-) diff --git a/packages/ui/docs-bundle/src/middleware.ts b/packages/ui/docs-bundle/src/middleware.ts index 7ab682ba25..5059521149 100644 --- a/packages/ui/docs-bundle/src/middleware.ts +++ b/packages/ui/docs-bundle/src/middleware.ts @@ -8,6 +8,7 @@ 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\/)/; @@ -31,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); } /** @@ -80,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; 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 6c6f1b700b..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 @@ -2,6 +2,7 @@ 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, OryAccessTokenSchema } from "@fern-ui/ui/auth"; import { NextRequest, NextResponse } from "next/server"; @@ -37,15 +38,15 @@ export default async function handler(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 index 41e733a63e..108841a2cd 100644 --- 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 @@ -1,6 +1,7 @@ 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"; @@ -20,7 +21,8 @@ export default async function handler(req: NextRequest): Promise { const domain = getXFernHostEdge(req); const edgeConfig = await getAuthEdgeConfig(domain); - const token = req.nextUrl.searchParams.get("fern_token"); + // 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}/`; @@ -32,7 +34,7 @@ export default async function handler(req: NextRequest): Promise { await verifyFernJWTConfig(token, edgeConfig); const res = NextResponse.redirect(redirectLocation); - res.cookies.set("fern_token", token, withSecureCookie()); + res.cookies.set(COOKIE_FERN_TOKEN, token, withSecureCookie()); return res; } catch (e) { // eslint-disable-next-line no-console 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 c1cdea72d8..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,4 +1,5 @@ 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"; @@ -25,7 +26,8 @@ export default async function responseApiHandler(req: NextApiRequest, res: NextA const xFernHost = getXFernHostNode(req); - const loader = DocsLoader.for(xFernHost, req.cookies.fern_token); + const fernToken = req.cookies[COOKIE_FERN_TOKEN]; + const loader = DocsLoader.for(xFernHost, fernToken); const root = await loader.root(); 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 218790124f..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 @@ -2,6 +2,7 @@ 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, OryAccessTokenSchema } from "@fern-ui/ui/auth"; import { NextRequest, NextResponse } from "next/server"; @@ -53,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/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/sitemap.xml.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/sitemap.xml.ts index 39e1f77000..56022ff25f 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/sitemap.xml.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/sitemap.xml.ts @@ -1,4 +1,5 @@ import { DocsLoader } from "@/server/DocsLoader"; +import { COOKIE_FERN_TOKEN } from "@/server/constants"; import { conformTrailingSlash } from "@/server/trailingSlash"; import { getXFernHostEdge } from "@/server/xfernhost/edge"; import { NodeCollector } from "@fern-api/fdr-sdk/navigation"; @@ -16,8 +17,8 @@ export default async function GET(req: NextRequest): Promise { const xFernHost = getXFernHostEdge(req); // load the root node - const fern_token = req.cookies.get("fern_token")?.value; - const root = await DocsLoader.for(xFernHost, fern_token).root(); + const fernToken = req.cookies.get(COOKIE_FERN_TOKEN)?.value; + const root = await DocsLoader.for(xFernHost, fernToken).root(); // collect all indexable page slugs const slugs = NodeCollector.collect(root).indexablePageSlugs; diff --git a/packages/ui/docs-bundle/src/server/DocsLoader.ts b/packages/ui/docs-bundle/src/server/DocsLoader.ts index da56004433..a783eb728d 100644 --- a/packages/ui/docs-bundle/src/server/DocsLoader.ts +++ b/packages/ui/docs-bundle/src/server/DocsLoader.ts @@ -1,6 +1,6 @@ import type { DocsV1Read, DocsV2Read } from "@fern-api/fdr-sdk"; import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; -import type { AuthEdgeConfig } from "@fern-ui/ui/auth"; +import type { AuthEdgeConfig, FernUser } from "@fern-ui/ui/auth"; import { verifyFernJWTConfig } from "./auth/FernJWT"; import { getAuthEdgeConfig } from "./auth/getAuthEdgeConfig"; import { AuthProps } from "./authProps"; @@ -8,27 +8,43 @@ import { loadWithUrl } from "./loadWithUrl"; import { pruneWithBasicTokenPublic } from "./withBasicTokenPublic"; export class DocsLoader { - static for(xFernHost: string, fern_token: string | undefined): DocsLoader { - return new DocsLoader(xFernHost, fern_token); + static for(xFernHost: string, fernToken: string | undefined): DocsLoader { + return new DocsLoader(xFernHost, fernToken); } private constructor( private xFernHost: string, - private fern_token: string | undefined, + private fernToken: string | undefined, ) {} - private authenticated: boolean = false; + private user: FernUser | undefined; private auth: AuthEdgeConfig | undefined; - public withAuth(auth: AuthEdgeConfig): DocsLoader { + public withAuth(auth: AuthEdgeConfig, user: FernUser | undefined): DocsLoader { this.auth = auth; + this.user = user; return this; } - private async loadAuth(): Promise { + 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 this.auth; + return { + authConfig: this.auth, + user: this.user, + }; } private loadForDocsUrlResponse: DocsV2Read.LoadDocsForUrlResponse | undefined; @@ -36,26 +52,15 @@ export class DocsLoader { this.loadForDocsUrlResponse = loadForDocsUrlResponse; return this; } + private async loadDocs(): Promise { if (!this.loadForDocsUrlResponse) { - const auth = await this.loadAuth(); - let authProps: AuthProps | undefined; - - try { - if (this.fern_token) { - const user = await verifyFernJWTConfig(this.fern_token, auth); - this.authenticated = true; - authProps = { - user, - token: this.fern_token, - }; - } - } catch (e) { - // eslint-disable-next-line no-console - console.error(e); - } + 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; } @@ -64,7 +69,7 @@ export class DocsLoader { } public async root(): Promise { - const auth = await this.loadAuth(); + const { authConfig, user } = await this.loadAuth(); const docs = await this.loadDocs(); if (!docs) { @@ -74,10 +79,10 @@ export class DocsLoader { let node = FernNavigation.utils.toRootNode(docs); // If the domain is basic_token_verification, we only want to include slugs that are allowed - if (auth?.type === "basic_token_verification" && !this.authenticated) { + if (authConfig?.type === "basic_token_verification" && !user) { try { // TODO: store this in cache - node = pruneWithBasicTokenPublic(auth, node); + node = pruneWithBasicTokenPublic(authConfig, node); } catch (e) { return undefined; } @@ -86,6 +91,8 @@ export class DocsLoader { 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 ?? {}; diff --git a/packages/ui/docs-bundle/src/server/auth/checkViewerAllowed.ts b/packages/ui/docs-bundle/src/server/auth/checkViewerAllowed.ts index a04bed1a98..ad93cab727 100644 --- a/packages/ui/docs-bundle/src/server/auth/checkViewerAllowed.ts +++ b/packages/ui/docs-bundle/src/server/auth/checkViewerAllowed.ts @@ -1,28 +1,29 @@ 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 fern_token = req.cookies.get("fern_token")?.value; + const fernToken = req.cookies.get(COOKIE_FERN_TOKEN)?.value; - return checkViewerAllowedPathname(auth, req.nextUrl.pathname, fern_token); + return checkViewerAllowedPathname(auth, req.nextUrl.pathname, fernToken); } export async function checkViewerAllowedPathname( auth: AuthEdgeConfig | undefined, pathname: string, - fern_token: string | undefined, + fernToken: string | undefined, ): Promise { if (auth?.type === "basic_token_verification") { if (withBasicTokenPublic(auth, pathname)) { return 200; } - if (fern_token == null) { + if (fernToken == null) { return 401; } else { - const verified = await verifyFernJWT(fern_token, auth.secret, auth.issuer); + const verified = await verifyFernJWT(fernToken, auth.secret, auth.issuer); if (!verified) { return 403; } diff --git a/packages/ui/docs-bundle/src/server/auth/withSecure.ts b/packages/ui/docs-bundle/src/server/auth/withSecure.ts index 2940dc6f87..03d980755d 100644 --- a/packages/ui/docs-bundle/src/server/auth/withSecure.ts +++ b/packages/ui/docs-bundle/src/server/auth/withSecure.ts @@ -1,6 +1,9 @@ import type { ResponseCookie } from "next/dist/compiled/@edge-runtime/cookies"; +const DEFAULT_EXPIRES = 30 * 24 * 60 * 60 * 1000; // 30 days + export const withSecureCookie = (opts?: Partial): Partial => ({ + expires: DEFAULT_EXPIRES, ...opts, secure: true, httpOnly: true, diff --git a/packages/ui/docs-bundle/src/server/authProps.ts b/packages/ui/docs-bundle/src/server/authProps.ts index e868336bf3..2a0ace4aaf 100644 --- a/packages/ui/docs-bundle/src/server/authProps.ts +++ b/packages/ui/docs-bundle/src/server/authProps.ts @@ -14,13 +14,13 @@ function withPrefix(token: string, partner: FernUser["partner"]): string { return `${partner}_${token}`; } -export async function withAuthProps(xFernHost: string, fern_token: string | null | undefined): Promise { - if (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(fern_token, config); - const token = withPrefix(fern_token, user.partner); + const user: FernUser = await verifyFernJWTConfig(fernToken, config); + const token = withPrefix(fernToken, user.partner); const authProps: AuthProps = { token, 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/getDynamicDocsPageProps.ts b/packages/ui/docs-bundle/src/server/getDynamicDocsPageProps.ts index c09afbc885..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.fern_token); + const authProps = await withAuthProps(xFernHost, cookies[COOKIE_FERN_TOKEN]); return getDocsPageProps(xFernHost, slug, authProps, cookies); } From 5ab1320917ac3378b5ad5a535c115382309cb036 Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Mon, 7 Oct 2024 11:21:40 -0400 Subject: [PATCH 27/27] remove expires --- packages/ui/docs-bundle/src/server/auth/withSecure.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/ui/docs-bundle/src/server/auth/withSecure.ts b/packages/ui/docs-bundle/src/server/auth/withSecure.ts index 03d980755d..2940dc6f87 100644 --- a/packages/ui/docs-bundle/src/server/auth/withSecure.ts +++ b/packages/ui/docs-bundle/src/server/auth/withSecure.ts @@ -1,9 +1,6 @@ import type { ResponseCookie } from "next/dist/compiled/@edge-runtime/cookies"; -const DEFAULT_EXPIRES = 30 * 24 * 60 * 60 * 1000; // 30 days - export const withSecureCookie = (opts?: Partial): Partial => ({ - expires: DEFAULT_EXPIRES, ...opts, secure: true, httpOnly: true,