From b2122c4073501501989f3302ed01e06207575a73 Mon Sep 17 00:00:00 2001 From: Shahed Nasser Date: Tue, 22 Oct 2024 18:20:06 +0300 Subject: [PATCH] docs: fixes and refactoring for API reference (#9708) * docs: fixes and refactoring for API reference * add route caching * remove caching * use next cache --- www/apps/api-reference/app/admin/page.tsx | 35 ++++-- .../api-reference/app/api/schema/route.ts | 16 +-- www/apps/api-reference/app/store/page.tsx | 35 ++++-- .../components/MDXComponents/H2/index.tsx | 12 +- .../Parameters/Types/Object/index.tsx | 7 +- .../components/Tags/Operation/index.tsx | 20 ++- .../components/Tags/Paths/index.tsx | 36 +----- .../components/Tags/Section/Schema/index.tsx | 34 ++++-- .../components/Tags/Section/index.tsx | 88 ++++++++------ .../api-reference/components/Tags/index.tsx | 114 ++---------------- www/apps/api-reference/lib/index.ts | 20 +++ .../api-reference/providers/base-specs.tsx | 82 +++++++++++-- www/apps/api-reference/providers/index.tsx | 19 ++- www/apps/api-reference/providers/sidebar.tsx | 11 -- www/apps/api-reference/utils/dereference.ts | 3 + .../api-reference/utils/get-paths-of-tag.ts | 13 +- .../api-reference/utils/get-schema-content.ts | 29 +++++ .../src/components/RootProviders/index.tsx | 2 - .../docs-ui/src/components/Sidebar/index.tsx | 24 ++-- .../docs-ui/src/providers/Sidebar/index.tsx | 54 ++++++++- 20 files changed, 377 insertions(+), 277 deletions(-) create mode 100644 www/apps/api-reference/lib/index.ts create mode 100644 www/apps/api-reference/utils/get-schema-content.ts diff --git a/www/apps/api-reference/app/admin/page.tsx b/www/apps/api-reference/app/admin/page.tsx index 7c27b2652d..619896df30 100644 --- a/www/apps/api-reference/app/admin/page.tsx +++ b/www/apps/api-reference/app/admin/page.tsx @@ -3,22 +3,35 @@ import AdminContent from "../_mdx/admin.mdx" import Tags from "@/components/Tags" import PageTitleProvider from "@/providers/page-title" import { H1 } from "docs-ui" +import { getBaseSpecs } from "../../lib" +import BaseSpecsProvider from "../../providers/base-specs" +import clsx from "clsx" + +const AdminPage = async () => { + const data = await getBaseSpecs("admin") -const ReferencePage = async () => { return ( - - -

- Medusa V2 Admin API Reference -

- - -
-
+ + + +

+ Medusa V2 Admin API Reference +

+ + +
+
+
) } -export default ReferencePage +export default AdminPage export function generateMetadata() { return { diff --git a/www/apps/api-reference/app/api/schema/route.ts b/www/apps/api-reference/app/api/schema/route.ts index fb0ec3448a..11fc281bbe 100644 --- a/www/apps/api-reference/app/api/schema/route.ts +++ b/www/apps/api-reference/app/api/schema/route.ts @@ -1,9 +1,7 @@ import { NextResponse } from "next/server" -import { SchemaObject } from "../../../types/openapi" import path from "path" -import { existsSync, promises as fs } from "fs" -import { parseDocument } from "yaml" -import dereference from "../../../utils/dereference" +import { existsSync } from "fs" +import getSchemaContent from "../../../utils/get-schema-content" export async function GET(request: Request) { const { searchParams } = new URL(request.url) @@ -59,14 +57,8 @@ export async function GET(request: Request) { ) } - const schemaContent = await fs.readFile(schemaPath, "utf-8") - const schema = parseDocument(schemaContent).toJS() as SchemaObject - - // resolve references in schema - const dereferencedDocument = await dereference({ - basePath: baseSchemasPath, - schemas: [schema], - }) + const { dereferencedDocument, originalSchema: schema } = + await getSchemaContent(schemaPath, baseSchemasPath) return NextResponse.json( { diff --git a/www/apps/api-reference/app/store/page.tsx b/www/apps/api-reference/app/store/page.tsx index be5006eb33..96bc547536 100644 --- a/www/apps/api-reference/app/store/page.tsx +++ b/www/apps/api-reference/app/store/page.tsx @@ -3,22 +3,35 @@ import StoreContent from "../_mdx/store.mdx" import Tags from "@/components/Tags" import PageTitleProvider from "@/providers/page-title" import { H1 } from "docs-ui" +import { getBaseSpecs } from "../../lib" +import BaseSpecsProvider from "../../providers/base-specs" +import clsx from "clsx" + +const StorePage = async () => { + const data = await getBaseSpecs("store") -const ReferencePage = async () => { return ( - - -

- Medusa V2 Store API Reference -

- - -
-
+ + + +

+ Medusa V2 Store API Reference +

+ + +
+
+
) } -export default ReferencePage +export default StorePage export function generateMetadata() { return { diff --git a/www/apps/api-reference/components/MDXComponents/H2/index.tsx b/www/apps/api-reference/components/MDXComponents/H2/index.tsx index ec148b3a22..15ce67062f 100644 --- a/www/apps/api-reference/components/MDXComponents/H2/index.tsx +++ b/www/apps/api-reference/components/MDXComponents/H2/index.tsx @@ -3,12 +3,13 @@ import { useScrollController, useSidebar, H2 as UiH2 } from "docs-ui" import { useEffect, useMemo, useRef, useState } from "react" import getSectionId from "../../../utils/get-section-id" +import { SidebarItem } from "types" type H2Props = React.HTMLAttributes const H2 = ({ children, ...props }: H2Props) => { const headingRef = useRef(null) - const { activePath, addItems } = useSidebar() + const { activePath, addItems, removeItems } = useSidebar() const { scrollableElement, scrollToElement } = useScrollController() const [scrolledFirstTime, setScrolledFirstTime] = useState(false) @@ -28,14 +29,19 @@ const H2 = ({ children, ...props }: H2Props) => { }, [scrollableElement, headingRef, id]) useEffect(() => { - addItems([ + const item: SidebarItem[] = [ { type: "link", path: `${id}`, title: children as string, loaded: true, }, - ]) + ] + addItems(item) + + return () => { + removeItems(item) + } }, [id]) return ( diff --git a/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Object/index.tsx b/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Object/index.tsx index 497a21da50..d86f589989 100644 --- a/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Object/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Object/index.tsx @@ -7,7 +7,7 @@ import type { TagOperationParametersProps } from "../.." import type { TagsOperationParametersNestedProps } from "../../Nested" import checkRequired from "@/utils/check-required" import { Loading, type DetailsProps } from "docs-ui" -import { useMemo } from "react" +import { Fragment, useMemo } from "react" const TagOperationParameters = dynamic( async () => import("../.."), @@ -101,20 +101,19 @@ const TagOperationParametersObject = ({ const content = ( <> {sortedProperties.map((property, index) => ( - <> + {index !== 0 &&
} - +
))} ) diff --git a/www/apps/api-reference/components/Tags/Operation/index.tsx b/www/apps/api-reference/components/Tags/Operation/index.tsx index 2b4a4ed05f..df44a3c331 100644 --- a/www/apps/api-reference/components/Tags/Operation/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/index.tsx @@ -7,7 +7,12 @@ import getSectionId from "@/utils/get-section-id" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import dynamic from "next/dynamic" import { useInView } from "react-intersection-observer" -import { isElmWindow, useScrollController, useSidebar } from "docs-ui" +import { + isElmWindow, + useIsBrowser, + useScrollController, + useSidebar, +} from "docs-ui" import type { TagOperationCodeSectionProps } from "./CodeSection" import TagsOperationDescriptionSection from "./DescriptionSection" import DividedLayout from "@/layouts/Divided" @@ -44,9 +49,14 @@ const TagOperation = ({ const nodeRef = useRef(null) const { loading, removeLoading } = useLoading() const { scrollableElement, scrollToTop } = useScrollController() + const { isBrowser } = useIsBrowser() const root = useMemo(() => { + if (!isBrowser) { + return + } + return isElmWindow(scrollableElement) ? document.body : scrollableElement - }, [scrollableElement]) + }, [isBrowser, scrollableElement]) const { ref } = useInView({ threshold: 0.3, rootMargin: `112px 0px 112px 0px`, @@ -83,6 +93,10 @@ const TagOperation = ({ ) const scrollIntoView = useCallback(() => { + if (!isBrowser) { + return + } + if (nodeRef.current && !checkElementInViewport(nodeRef.current, 0)) { const elm = nodeRef.current as HTMLElement scrollToTop( @@ -91,7 +105,7 @@ const TagOperation = ({ ) } setShow(true) - }, [scrollToTop, nodeRef]) + }, [scrollToTop, nodeRef, isBrowser]) useEffect(() => { if (nodeRef && nodeRef.current) { diff --git a/www/apps/api-reference/components/Tags/Paths/index.tsx b/www/apps/api-reference/components/Tags/Paths/index.tsx index 1c19089857..431d2e92e3 100644 --- a/www/apps/api-reference/components/Tags/Paths/index.tsx +++ b/www/apps/api-reference/components/Tags/Paths/index.tsx @@ -1,21 +1,16 @@ "use client" -import getSectionId from "@/utils/get-section-id" import type { OpenAPIV3 } from "openapi-types" -import useSWR from "swr" import type { Operation, PathsObject } from "@/types/openapi" -import { useSidebar, swrFetcher } from "docs-ui" -import { Fragment, useEffect, useMemo } from "react" +import { useSidebar } from "docs-ui" +import { Fragment, useEffect } from "react" import dynamic from "next/dynamic" import type { TagOperationProps } from "../Operation" -import { useArea } from "@/providers/area" import clsx from "clsx" -import { useBaseSpecs } from "@/providers/base-specs" import getTagChildSidebarItems from "@/utils/get-tag-child-sidebar-items" import { useLoading } from "@/providers/loading" import DividedLoading from "@/components/DividedLoading" import { SidebarItemSections, SidebarItem, SidebarItemCategory } from "types" -import basePathUrl from "../../../utils/base-path-url" const TagOperation = dynamic( async () => import("../Operation") @@ -23,35 +18,12 @@ const TagOperation = dynamic( export type TagPathsProps = { tag: OpenAPIV3.TagObject + paths: PathsObject } & React.HTMLAttributes -const TagPaths = ({ tag, className }: TagPathsProps) => { - const tagSlugName = useMemo(() => getSectionId([tag.name]), [tag]) - const { area } = useArea() +const TagPaths = ({ tag, className, paths }: TagPathsProps) => { const { items, addItems, findItemInSection } = useSidebar() - const { baseSpecs } = useBaseSpecs() const { loading } = useLoading() - // if paths are already loaded since through - // the expanded field, they're loaded directly - // otherwise, they're loaded using the API route - let paths: PathsObject = - baseSpecs?.expandedTags && - Object.hasOwn(baseSpecs.expandedTags, tagSlugName) - ? baseSpecs.expandedTags[tagSlugName] - : {} - const { data } = useSWR<{ - paths: PathsObject - }>( - !Object.keys(paths).length - ? basePathUrl(`/api/tag?tagName=${tagSlugName}&area=${area}`) - : null, - swrFetcher, - { - errorRetryInterval: 2000, - } - ) - - paths = data?.paths || paths useEffect(() => { if (paths) { diff --git a/www/apps/api-reference/components/Tags/Section/Schema/index.tsx b/www/apps/api-reference/components/Tags/Section/Schema/index.tsx index c542fe2db9..29a12d258c 100644 --- a/www/apps/api-reference/components/Tags/Section/Schema/index.tsx +++ b/www/apps/api-reference/components/Tags/Section/Schema/index.tsx @@ -1,10 +1,13 @@ -import { useEffect, useMemo, useRef, useState } from "react" +"use client" + +import { useEffect, useMemo, useRef } from "react" import { SchemaObject } from "../../../../types/openapi" import TagOperationParameters from "../../Operation/Parameters" import { Badge, CodeBlock, isElmWindow, + useIsBrowser, useScrollController, useSidebar, } from "docs-ui" @@ -16,7 +19,6 @@ import useSchemaExample from "../../../../hooks/use-schema-example" import { InView } from "react-intersection-observer" import checkElementInViewport from "../../../../utils/check-element-in-viewport" import { singular } from "pluralize" -import useResizeObserver from "@react-hook/resize-observer" import clsx from "clsx" export type TagSectionSchemaProps = { @@ -26,7 +28,6 @@ export type TagSectionSchemaProps = { const TagSectionSchema = ({ schema, tagName }: TagSectionSchemaProps) => { const paramsRef = useRef(null) - const [maxCodeHeight, setMaxCodeHeight] = useState(0) const { addItems, setActivePath, activePath } = useSidebar() const tagSlugName = useMemo(() => getSectionId([tagName]), [tagName]) const formattedName = useMemo( @@ -43,14 +44,16 @@ const TagSectionSchema = ({ schema, tagName }: TagSectionSchemaProps) => { skipNonRequired: false, }, }) - useResizeObserver(paramsRef, () => { - setMaxCodeHeight(paramsRef.current?.clientHeight || 0) - }) + const { isBrowser } = useIsBrowser() const { scrollableElement, scrollToElement } = useScrollController() const root = useMemo(() => { + if (!isBrowser) { + return + } + return isElmWindow(scrollableElement) ? document.body : scrollableElement - }, [scrollableElement]) + }, [isBrowser, scrollableElement]) useEffect(() => { addItems( @@ -77,18 +80,26 @@ const TagSectionSchema = ({ schema, tagName }: TagSectionSchemaProps) => { }, [formattedName]) useEffect(() => { + if (!isBrowser) { + return + } + if (schemaSlug === (activePath || location.hash.replace("#", ""))) { const elm = document.getElementById(schemaSlug) as HTMLElement if (!checkElementInViewport(elm, 0)) { scrollToElement(elm) } } - }, [activePath, schemaSlug]) + }, [activePath, schemaSlug, isBrowser]) const handleViewChange = ( inView: boolean, entry: IntersectionObserverEntry ) => { + if (!isBrowser) { + return + } + const section = entry.target if ( @@ -106,7 +117,7 @@ const TagSectionSchema = ({ schema, tagName }: TagSectionSchemaProps) => { { source={examples[0].content} lang="json" title={`The ${formattedName} Object`} - className={clsx(maxCodeHeight && "overflow-auto")} + className={clsx("overflow-auto")} style={{ - // remove padding + extra space - maxHeight: maxCodeHeight ? maxCodeHeight - 212 : "unset", + maxHeight: "100vh", }} /> )} diff --git a/www/apps/api-reference/components/Tags/Section/index.tsx b/www/apps/api-reference/components/Tags/Section/index.tsx index 291c4b5dd0..774dc4d34f 100644 --- a/www/apps/api-reference/components/Tags/Section/index.tsx +++ b/www/apps/api-reference/components/Tags/Section/index.tsx @@ -6,13 +6,13 @@ import { useEffect, useMemo, useState } from "react" import { isElmWindow, swrFetcher, + useIsBrowser, useScrollController, useSidebar, } from "docs-ui" import dynamic from "next/dynamic" import type { SectionProps } from "../../Section" import type { MDXContentClientProps } from "../../MDXContent/Client" -import TagPaths from "../Paths" import DividedLayout from "@/layouts/Divided" import LoadingProvider from "@/providers/loading" import SectionContainer from "../../Section/Container" @@ -22,11 +22,12 @@ import clsx from "clsx" import { Feedback, Loading, Link } from "docs-ui" import { usePathname, useRouter } from "next/navigation" import formatReportLink from "@/utils/format-report-link" -import { SchemaObject, TagObject } from "@/types/openapi" -import useSWR from "swr" +import { PathsObject, SchemaObject, TagObject } from "@/types/openapi" import { TagSectionSchemaProps } from "./Schema" -import basePathUrl from "../../../utils/base-path-url" import checkElementInViewport from "../../../utils/check-element-in-viewport" +import TagPaths from "../Paths" +import useSWR from "swr" +import basePathUrl from "../../../utils/base-path-url" export type TagSectionProps = { tag: TagObject @@ -47,39 +48,30 @@ const MDXContentClient = dynamic( } ) as React.FC -const TagSection = ({ tag }: TagSectionProps) => { +const TagSectionComponent = ({ tag }: TagSectionProps) => { const { activePath, setActivePath } = useSidebar() const router = useRouter() - const [loadPaths, setLoadPaths] = useState(false) + const [loadData, setLoadData] = useState(false) const slugTagName = useMemo(() => getSectionId([tag.name]), [tag]) const { area } = useArea() const pathname = usePathname() const { scrollableElement, scrollToTop } = useScrollController() - const { data } = useSWR<{ - schema: SchemaObject - }>( - tag["x-associatedSchema"] - ? basePathUrl( - `/api/schema?name=${tag["x-associatedSchema"].$ref}&area=${area}` - ) - : null, - swrFetcher, - { - errorRetryInterval: 2000, - } - ) - const associatedSchema = data?.schema + const { isBrowser } = useIsBrowser() const root = useMemo(() => { + if (!isBrowser) { + return + } + return isElmWindow(scrollableElement) ? document.body : scrollableElement - }, [scrollableElement]) - const { ref } = useInView({ + }, [scrollableElement, isBrowser]) + const { ref, inView } = useInView({ threshold: 0.8, rootMargin: `112px 0px 112px 0px`, root, onChange: (inView) => { - if (inView && !loadPaths) { - setLoadPaths(true) + if (inView && !loadData) { + setLoadData(true) } if (inView) { // ensure that the hash link doesn't change if it links to an inner path @@ -97,8 +89,36 @@ const TagSection = ({ tag }: TagSectionProps) => { } }, }) + const { data: schemaData } = useSWR<{ + schema: SchemaObject + }>( + loadData && tag["x-associatedSchema"] + ? basePathUrl( + `/api/schema?name=${tag["x-associatedSchema"].$ref}&area=${area}` + ) + : null, + swrFetcher, + { + errorRetryInterval: 2000, + } + ) + const { data: pathsData } = useSWR<{ + paths: PathsObject + }>( + loadData + ? basePathUrl(`/api/tag?tagName=${slugTagName}&area=${area}`) + : null, + swrFetcher, + { + errorRetryInterval: 2000, + } + ) useEffect(() => { + if (!isBrowser) { + return + } + if (activePath && activePath.includes(slugTagName)) { const tagName = activePath.split("_") if (tagName.length === 1 && tagName[0] === slugTagName) { @@ -110,18 +130,18 @@ const TagSection = ({ tag }: TagSectionProps) => { ) } } else if (tagName.length > 1 && tagName[0] === slugTagName) { - setLoadPaths(true) + setLoadData(true) } } - }, [slugTagName, activePath]) + }, [slugTagName, activePath, isBrowser]) return (

{tag.name}

@@ -159,17 +179,17 @@ const TagSection = ({ tag }: TagSectionProps) => { } codeContent={<>} /> - {associatedSchema && ( - + {schemaData && ( + )} - {loadPaths && ( + {loadData && pathsData && ( - + )} - {!loadPaths && } + {!loadData && }
) } -export default TagSection +export default TagSectionComponent diff --git a/www/apps/api-reference/components/Tags/index.tsx b/www/apps/api-reference/components/Tags/index.tsx index f38f3e3a60..baab29971f 100644 --- a/www/apps/api-reference/components/Tags/index.tsx +++ b/www/apps/api-reference/components/Tags/index.tsx @@ -1,120 +1,20 @@ -"use client" - -import type { OpenAPIV3 } from "openapi-types" -import { useEffect, useState } from "react" -import useSWR from "swr" -import { useBaseSpecs } from "@/providers/base-specs" +import { OpenAPIV3 } from "openapi-types" +import { TagSectionProps } from "./Section" import dynamic from "next/dynamic" -import type { TagSectionProps } from "./Section" -import { useArea } from "@/providers/area" -import { swrFetcher, useSidebar } from "docs-ui" -import getSectionId from "@/utils/get-section-id" -import { ExpandedDocument } from "@/types/openapi" -import getTagChildSidebarItems from "@/utils/get-tag-child-sidebar-items" -import { SidebarItem, SidebarItemSections } from "types" -import basePathUrl from "../../utils/base-path-url" -import { useRouter } from "next/navigation" const TagSection = dynamic( async () => import("./Section") ) as React.FC -export type TagsProps = React.HTMLAttributes - -function getCurrentTag() { - return typeof location !== "undefined" - ? location.hash.replace("#", "").split("_")[0] - : "" +type TagsProps = { + tags?: OpenAPIV3.TagObject[] } -const Tags = () => { - const [tags, setTags] = useState([]) - const [loadData, setLoadData] = useState(false) - const [expand, setExpand] = useState("") - const { baseSpecs, setBaseSpecs } = useBaseSpecs() - const { activePath, addItems, setActivePath } = useSidebar() - const { area, prevArea } = useArea() - const router = useRouter() - - const { data } = useSWR( - loadData && !baseSpecs - ? basePathUrl(`/api/base-specs?area=${area}&expand=${expand}`) - : null, - swrFetcher, - { - errorRetryInterval: 2000, - } - ) - - useEffect(() => { - setExpand(getCurrentTag()) - }, []) - - useEffect(() => { - setLoadData(true) - }, [expand]) - - useEffect(() => { - if (data) { - setBaseSpecs(data) - } - if (data?.tags) { - setTags(data.tags) - } - }, [data, setBaseSpecs]) - - useEffect(() => { - if (baseSpecs) { - if (prevArea !== area) { - setBaseSpecs(null) - setLoadData(true) - return - } - - const itemsToAdd: SidebarItem[] = [ - { - type: "separator", - }, - ] - - if (baseSpecs.tags) { - baseSpecs.tags.forEach((tag) => { - const tagPathName = getSectionId([tag.name.toLowerCase()]) - const childItems = - baseSpecs.expandedTags && - Object.hasOwn(baseSpecs.expandedTags, tagPathName) - ? getTagChildSidebarItems(baseSpecs.expandedTags[tagPathName]) - : [] - itemsToAdd.push({ - type: "category", - title: tag.name, - children: childItems, - loaded: childItems.length > 0, - showLoadingIfEmpty: true, - onOpen: () => { - if (location.hash !== tagPathName) { - router.push(`#${tagPathName}`, { - scroll: false, - }) - } - if (activePath !== tagPathName) { - setActivePath(tagPathName) - } - }, - }) - }) - } - - addItems(itemsToAdd, { - section: SidebarItemSections.DEFAULT, - }) - } - }, [baseSpecs, prevArea, area]) - +const Tags = ({ tags }: TagsProps) => { return ( <> - {tags.map((tag, index) => ( - + {tags?.map((tag) => ( + ))} ) diff --git a/www/apps/api-reference/lib/index.ts b/www/apps/api-reference/lib/index.ts new file mode 100644 index 0000000000..2584c817c5 --- /dev/null +++ b/www/apps/api-reference/lib/index.ts @@ -0,0 +1,20 @@ +"use server" + +import { Area, ExpandedDocument } from "../types/openapi" + +const URL = `${process.env.NEXT_PUBLIC_BASE_URL}${process.env.NEXT_PUBLIC_BASE_PATH}` + +export async function getBaseSpecs(area: Area) { + try { + const res = await fetch(`${URL}/api/base-specs?area=${area}`, { + next: { + revalidate: 3000, + tags: [area], + }, + }).then(async (res) => res.json()) + + return res as ExpandedDocument + } catch (e) { + console.error(e) + } +} diff --git a/www/apps/api-reference/providers/base-specs.tsx b/www/apps/api-reference/providers/base-specs.tsx index fbe43b37bd..b7c0ffca8d 100644 --- a/www/apps/api-reference/providers/base-specs.tsx +++ b/www/apps/api-reference/providers/base-specs.tsx @@ -1,28 +1,34 @@ "use client" import { ExpandedDocument, SecuritySchemeObject } from "@/types/openapi" -import { ReactNode, createContext, useContext, useState } from "react" +import { + ReactNode, + createContext, + useCallback, + useContext, + useEffect, +} from "react" +import { SidebarItem, SidebarItemSections } from "types" +import getSectionId from "../utils/get-section-id" +import getTagChildSidebarItems from "../utils/get-tag-child-sidebar-items" +import { useRouter } from "next/navigation" +import { useSidebar } from "docs-ui" type BaseSpecsContextType = { - baseSpecs: ExpandedDocument | null - setBaseSpecs: React.Dispatch> + baseSpecs: ExpandedDocument | undefined getSecuritySchema: (securityName: string) => SecuritySchemeObject | null } const BaseSpecsContext = createContext(null) type BaseSpecsProviderProps = { - initialSpecs?: ExpandedDocument | null + baseSpecs: ExpandedDocument | undefined children?: ReactNode } -const BaseSpecsProvider = ({ - children, - initialSpecs = null, -}: BaseSpecsProviderProps) => { - const [baseSpecs, setBaseSpecs] = useState( - initialSpecs - ) +const BaseSpecsProvider = ({ children, baseSpecs }: BaseSpecsProviderProps) => { + const router = useRouter() + const { activePath, addItems, setActivePath, resetItems } = useSidebar() const getSecuritySchema = ( securityName: string @@ -43,11 +49,63 @@ const BaseSpecsProvider = ({ return null } + const handleOpen = useCallback( + (tagPathName: string) => { + if (location.hash !== tagPathName) { + router.push(`#${tagPathName}`, { + scroll: false, + }) + } + if (activePath !== tagPathName) { + setActivePath(tagPathName) + } + }, + [activePath, router, setActivePath] + ) + + useEffect(() => { + if (!baseSpecs) { + return + } + + const itemsToAdd: SidebarItem[] = [ + { + type: "separator", + }, + ] + + baseSpecs.tags?.forEach((tag) => { + const tagPathName = getSectionId([tag.name.toLowerCase()]) + const childItems = + baseSpecs.expandedTags && + Object.hasOwn(baseSpecs.expandedTags, tagPathName) + ? getTagChildSidebarItems(baseSpecs.expandedTags[tagPathName]) + : [] + itemsToAdd.push({ + type: "category", + title: tag.name, + children: childItems, + loaded: childItems.length > 0, + showLoadingIfEmpty: true, + onOpen: () => { + handleOpen(tagPathName) + }, + }) + }) + + addItems(itemsToAdd, { + section: SidebarItemSections.DEFAULT, + }) + + return () => { + resetItems() + } + }, [baseSpecs]) + return ( diff --git a/www/apps/api-reference/providers/index.tsx b/www/apps/api-reference/providers/index.tsx index a160fe596a..a95d278694 100644 --- a/www/apps/api-reference/providers/index.tsx +++ b/www/apps/api-reference/providers/index.tsx @@ -1,12 +1,9 @@ -"use client" - import { AnalyticsProvider, PageLoadingProvider, ScrollControllerProvider, SiteConfigProvider, } from "docs-ui" -import BaseSpecsProvider from "./base-specs" import SidebarProvider from "./sidebar" import SearchProvider from "./search" import { config } from "../config" @@ -21,15 +18,13 @@ const Providers = ({ children }: ProvidersProps) => { - - - - - {children} - - - - + + + + {children} + + + diff --git a/www/apps/api-reference/providers/sidebar.tsx b/www/apps/api-reference/providers/sidebar.tsx index 9df31784dc..59c6a662c5 100644 --- a/www/apps/api-reference/providers/sidebar.tsx +++ b/www/apps/api-reference/providers/sidebar.tsx @@ -3,12 +3,9 @@ import { SidebarProvider as UiSidebarProvider, usePageLoading, - usePrevious, useScrollController, } from "docs-ui" import { config } from "../config" -import { useCallback } from "react" -import { usePathname } from "next/navigation" type SidebarProviderProps = { children?: React.ReactNode @@ -17,13 +14,6 @@ type SidebarProviderProps = { const SidebarProvider = ({ children }: SidebarProviderProps) => { const { isLoading, setIsLoading } = usePageLoading() const { scrollableElement } = useScrollController() - const pathname = usePathname() - const prevPathName = usePrevious(pathname) - - const resetOnCondition = useCallback( - () => prevPathName !== undefined && pathname !== prevPathName, - [pathname, prevPathName] - ) return ( { shouldHandleHashChange={true} scrollableElement={scrollableElement} initialItems={config.sidebar} - resetOnCondition={resetOnCondition} persistState={false} projectName="api" > diff --git a/www/apps/api-reference/utils/dereference.ts b/www/apps/api-reference/utils/dereference.ts index 9a1f10bac7..a96b0c9204 100644 --- a/www/apps/api-reference/utils/dereference.ts +++ b/www/apps/api-reference/utils/dereference.ts @@ -53,6 +53,9 @@ export default async function dereference({ canParse: /.*/, }, }, + dereference: { + circular: "ignore", + }, })) as unknown as Document return document diff --git a/www/apps/api-reference/utils/get-paths-of-tag.ts b/www/apps/api-reference/utils/get-paths-of-tag.ts index 5d91cd51b5..012e4767c1 100644 --- a/www/apps/api-reference/utils/get-paths-of-tag.ts +++ b/www/apps/api-reference/utils/get-paths-of-tag.ts @@ -5,8 +5,9 @@ import type { Operation, Document, ParsedPathItemObject } from "@/types/openapi" import readSpecDocument from "./read-spec-document" import getSectionId from "./get-section-id" import dereference from "./dereference" +import { unstable_cache } from "next/cache" -export default async function getPathsOfTag( +async function getPathsOfTag_( tagName: string, area: string ): Promise { @@ -47,3 +48,13 @@ export default async function getPathsOfTag( paths: documents, }) } + +const getPathsOfTag = unstable_cache( + async (tagName: string, area: string) => getPathsOfTag_(tagName, area), + ["tag-paths"], + { + revalidate: 3600, + } +) + +export default getPathsOfTag diff --git a/www/apps/api-reference/utils/get-schema-content.ts b/www/apps/api-reference/utils/get-schema-content.ts new file mode 100644 index 0000000000..b261762a6c --- /dev/null +++ b/www/apps/api-reference/utils/get-schema-content.ts @@ -0,0 +1,29 @@ +import { promises as fs } from "fs" +import { parseDocument } from "yaml" +import { SchemaObject } from "../types/openapi" +import dereference from "./dereference" +import { unstable_cache } from "next/cache" + +async function getSchemaContent_(schemaPath: string, baseSchemasPath: string) { + const schemaContent = await fs.readFile(schemaPath, "utf-8") + const schema = parseDocument(schemaContent).toJS() as SchemaObject + + // resolve references in schema + const dereferencedDocument = await dereference({ + basePath: baseSchemasPath, + schemas: [schema], + }) + + return { + dereferencedDocument, + originalSchema: schema, + } +} + +const getSchemaContent = unstable_cache( + async (schemaPath: string, baseSchemasPath: string) => + getSchemaContent_(schemaPath, baseSchemasPath), + ["tag-schema"] +) + +export default getSchemaContent diff --git a/www/packages/docs-ui/src/components/RootProviders/index.tsx b/www/packages/docs-ui/src/components/RootProviders/index.tsx index f11830c7f3..f472756909 100644 --- a/www/packages/docs-ui/src/components/RootProviders/index.tsx +++ b/www/packages/docs-ui/src/components/RootProviders/index.tsx @@ -1,5 +1,3 @@ -"use client" - import React from "react" import { BrowserProvider, diff --git a/www/packages/docs-ui/src/components/Sidebar/index.tsx b/www/packages/docs-ui/src/components/Sidebar/index.tsx index 306e6c0e88..e90978df0a 100644 --- a/www/packages/docs-ui/src/components/Sidebar/index.tsx +++ b/www/packages/docs-ui/src/components/Sidebar/index.tsx @@ -134,14 +134,22 @@ export const Sidebar = ({ {!sidebarItems.default.length && !staticSidebarItems && ( )} - {sidebarItems.default.map((item, index) => ( - - ))} + {sidebarItems.default.map((item, index) => { + const itemKey = + item.type === "separator" + ? index + : item.type === "link" + ? `${item.path}-${index}` + : `${item.title}-${index}` + return ( + + ) + })} diff --git a/www/packages/docs-ui/src/providers/Sidebar/index.tsx b/www/packages/docs-ui/src/providers/Sidebar/index.tsx index 4d35acb38d..b62602e58c 100644 --- a/www/packages/docs-ui/src/providers/Sidebar/index.tsx +++ b/www/packages/docs-ui/src/providers/Sidebar/index.tsx @@ -40,7 +40,8 @@ export type SidebarContextType = { setActivePath: (path: string | null) => void isLinkActive: (item: SidebarItem, checkChildren?: boolean) => boolean isChildrenActive: (item: SidebarItemCategory) => boolean - addItems: (item: SidebarItem[], options?: ActionOptionsType) => void + addItems: (items: SidebarItem[], options?: ActionOptionsType) => void + removeItems: (items: SidebarItem[]) => void findItemInSection: ( section: SidebarItem[], item: Partial, @@ -87,6 +88,10 @@ export type ActionType = type: "replace" replacementItems: SidebarSectionItems } + | { + type: "remove" + items: SidebarItem[] + } type LinksMap = Map @@ -152,10 +157,46 @@ const getLinksMap = ( return map } -export const reducer = (state: SidebarSectionItems, actionData: ActionType) => { +export const reducer = ( + state: SidebarSectionItems, + actionData: ActionType +): SidebarSectionItems => { if (actionData.type === "replace") { return actionData.replacementItems } + if (actionData.type === "remove") { + return { + ...state, + default: state.default.filter((item) => { + if (item.type === "separator") { + return true + } + + const found = actionData.items.some((itemToRemove) => { + if ( + itemToRemove.type === "separator" || + itemToRemove.type !== item.type + ) { + return false + } + + if ( + itemToRemove.type === "category" || + itemToRemove.type === "sub-category" + ) { + return itemToRemove.title === item.title + } + + return ( + itemToRemove.path === (item as SidebarItemLink).path && + itemToRemove.title === (item as SidebarItemLink).title + ) + }) + + return !found + }), + } + } const { type, options } = actionData let { items } = actionData @@ -306,6 +347,13 @@ export const SidebarProvider = ({ }) } + const removeItems = (itemsToRemove: SidebarItem[]) => { + dispatch({ + type: "remove", + items: itemsToRemove, + }) + } + const isLinkActive = useCallback( (item: SidebarItem, checkChildren = false): boolean => { if (item.type !== "link") { @@ -516,6 +564,7 @@ export const SidebarProvider = ({ useEffect(() => { if (resetOnCondition?.()) { resetItems() + setActivePath(null) } }, [resetOnCondition, resetItems]) @@ -575,6 +624,7 @@ export const SidebarProvider = ({ items, currentItems, addItems, + removeItems, activePath, setActivePath, isLinkActive: isLinkActive,