docs: fixes and refactoring for API reference (#9708)

* docs: fixes and refactoring for API reference

* add route caching

* remove caching

* use next cache
This commit is contained in:
Shahed Nasser authored and GitHub committed 2024-10-22 18:20:06 +03:00
1 parent 6b989353ac
commit b2122c4073
20 files changed
+351 -251

No files matched your search

+17 -4
View File
@@ -3,22 +3,35 @@ import AdminContent from "../_mdx/admin.mdx"
import Tags from "@/components/Tags" import Tags from "@/components/Tags"
import PageTitleProvider from "@/providers/page-title" import PageTitleProvider from "@/providers/page-title"
import { H1 } from "docs-ui" 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 ( return (
<BaseSpecsProvider baseSpecs={data}>
<AreaProvider area={"admin"}> <AreaProvider area={"admin"}>
<PageTitleProvider> <PageTitleProvider>
<H1 className="!h2-docs scroll-m-56 lg:pl-4" id="introduction"> <H1
className={clsx(
"!h2-docs lg:pl-4",
"scroll-m-[184px] lg:scroll-m-[264px]"
)}
id="introduction"
>
Medusa V2 Admin API Reference Medusa V2 Admin API Reference
</H1> </H1>
<AdminContent /> <AdminContent />
<Tags /> <Tags tags={data?.tags} />
</PageTitleProvider> </PageTitleProvider>
</AreaProvider> </AreaProvider>
</BaseSpecsProvider>
) )
} }
export default ReferencePage export default AdminPage
export function generateMetadata() { export function generateMetadata() {
return { return {
+4 -12
View File
@@ -1,9 +1,7 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { SchemaObject } from "../../../types/openapi"
import path from "path" import path from "path"
import { existsSync, promises as fs } from "fs" import { existsSync } from "fs"
import { parseDocument } from "yaml" import getSchemaContent from "../../../utils/get-schema-content"
import dereference from "../../../utils/dereference"
export async function GET(request: Request) { export async function GET(request: Request) {
const { searchParams } = new URL(request.url) 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 { dereferencedDocument, originalSchema: schema } =
const schema = parseDocument(schemaContent).toJS() as SchemaObject await getSchemaContent(schemaPath, baseSchemasPath)
// resolve references in schema
const dereferencedDocument = await dereference({
basePath: baseSchemasPath,
schemas: [schema],
})
return NextResponse.json( return NextResponse.json(
{ {
+17 -4
View File
@@ -3,22 +3,35 @@ import StoreContent from "../_mdx/store.mdx"
import Tags from "@/components/Tags" import Tags from "@/components/Tags"
import PageTitleProvider from "@/providers/page-title" import PageTitleProvider from "@/providers/page-title"
import { H1 } from "docs-ui" 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 ( return (
<BaseSpecsProvider baseSpecs={data}>
<AreaProvider area={"store"}> <AreaProvider area={"store"}>
<PageTitleProvider> <PageTitleProvider>
<H1 className="!h2-docs scroll-m-56 lg:pl-4" id="introduction"> <H1
className={clsx(
"!h2-docs lg:pl-4",
"scroll-m-[184px] lg:scroll-m-[264px]"
)}
id="introduction"
>
Medusa V2 Store API Reference Medusa V2 Store API Reference
</H1> </H1>
<StoreContent /> <StoreContent />
<Tags /> <Tags tags={data?.tags} />
</PageTitleProvider> </PageTitleProvider>
</AreaProvider> </AreaProvider>
</BaseSpecsProvider>
) )
} }
export default ReferencePage export default StorePage
export function generateMetadata() { export function generateMetadata() {
return { return {
@@ -3,12 +3,13 @@
import { useScrollController, useSidebar, H2 as UiH2 } from "docs-ui" import { useScrollController, useSidebar, H2 as UiH2 } from "docs-ui"
import { useEffect, useMemo, useRef, useState } from "react" import { useEffect, useMemo, useRef, useState } from "react"
import getSectionId from "../../../utils/get-section-id" import getSectionId from "../../../utils/get-section-id"
import { SidebarItem } from "types"
type H2Props = React.HTMLAttributes<HTMLHeadingElement> type H2Props = React.HTMLAttributes<HTMLHeadingElement>
const H2 = ({ children, ...props }: H2Props) => { const H2 = ({ children, ...props }: H2Props) => {
const headingRef = useRef<HTMLHeadingElement>(null) const headingRef = useRef<HTMLHeadingElement>(null)
const { activePath, addItems } = useSidebar() const { activePath, addItems, removeItems } = useSidebar()
const { scrollableElement, scrollToElement } = useScrollController() const { scrollableElement, scrollToElement } = useScrollController()
const [scrolledFirstTime, setScrolledFirstTime] = useState(false) const [scrolledFirstTime, setScrolledFirstTime] = useState(false)
@@ -28,14 +29,19 @@ const H2 = ({ children, ...props }: H2Props) => {
}, [scrollableElement, headingRef, id]) }, [scrollableElement, headingRef, id])
useEffect(() => { useEffect(() => {
addItems([ const item: SidebarItem[] = [
{ {
type: "link", type: "link",
path: `${id}`, path: `${id}`,
title: children as string, title: children as string,
loaded: true, loaded: true,
}, },
]) ]
addItems(item)
return () => {
removeItems(item)
}
}, [id]) }, [id])
return ( return (
@@ -7,7 +7,7 @@ import type { TagOperationParametersProps } from "../.."
import type { TagsOperationParametersNestedProps } from "../../Nested" import type { TagsOperationParametersNestedProps } from "../../Nested"
import checkRequired from "@/utils/check-required" import checkRequired from "@/utils/check-required"
import { Loading, type DetailsProps } from "docs-ui" import { Loading, type DetailsProps } from "docs-ui"
import { useMemo } from "react" import { Fragment, useMemo } from "react"
const TagOperationParameters = dynamic<TagOperationParametersProps>( const TagOperationParameters = dynamic<TagOperationParametersProps>(
async () => import("../.."), async () => import("../.."),
@@ -101,20 +101,19 @@ const TagOperationParametersObject = ({
const content = ( const content = (
<> <>
{sortedProperties.map((property, index) => ( {sortedProperties.map((property, index) => (
<> <Fragment key={index}>
{index !== 0 && <hr className="bg-medusa-border-base my-0" />} {index !== 0 && <hr className="bg-medusa-border-base my-0" />}
<TagOperationParameters <TagOperationParameters
schemaObject={{ schemaObject={{
...properties[property], ...properties[property],
parameterName: property, parameterName: property,
}} }}
key={index}
isRequired={ isRequired={
properties[property].isRequired || properties[property].isRequired ||
checkRequired(schema, property) checkRequired(schema, property)
} }
/> />
</> </Fragment>
))} ))}
</> </>
) )
@@ -7,7 +7,12 @@ import getSectionId from "@/utils/get-section-id"
import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import dynamic from "next/dynamic" import dynamic from "next/dynamic"
import { useInView } from "react-intersection-observer" 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 type { TagOperationCodeSectionProps } from "./CodeSection"
import TagsOperationDescriptionSection from "./DescriptionSection" import TagsOperationDescriptionSection from "./DescriptionSection"
import DividedLayout from "@/layouts/Divided" import DividedLayout from "@/layouts/Divided"
@@ -44,9 +49,14 @@ const TagOperation = ({
const nodeRef = useRef<Element | null>(null) const nodeRef = useRef<Element | null>(null)
const { loading, removeLoading } = useLoading() const { loading, removeLoading } = useLoading()
const { scrollableElement, scrollToTop } = useScrollController() const { scrollableElement, scrollToTop } = useScrollController()
const { isBrowser } = useIsBrowser()
const root = useMemo(() => { const root = useMemo(() => {
if (!isBrowser) {
return
}
return isElmWindow(scrollableElement) ? document.body : scrollableElement return isElmWindow(scrollableElement) ? document.body : scrollableElement
}, [scrollableElement]) }, [isBrowser, scrollableElement])
const { ref } = useInView({ const { ref } = useInView({
threshold: 0.3, threshold: 0.3,
rootMargin: `112px 0px 112px 0px`, rootMargin: `112px 0px 112px 0px`,
@@ -83,6 +93,10 @@ const TagOperation = ({
) )
const scrollIntoView = useCallback(() => { const scrollIntoView = useCallback(() => {
if (!isBrowser) {
return
}
if (nodeRef.current && !checkElementInViewport(nodeRef.current, 0)) { if (nodeRef.current && !checkElementInViewport(nodeRef.current, 0)) {
const elm = nodeRef.current as HTMLElement const elm = nodeRef.current as HTMLElement
scrollToTop( scrollToTop(
@@ -91,7 +105,7 @@ const TagOperation = ({
) )
} }
setShow(true) setShow(true)
}, [scrollToTop, nodeRef]) }, [scrollToTop, nodeRef, isBrowser])
useEffect(() => { useEffect(() => {
if (nodeRef && nodeRef.current) { if (nodeRef && nodeRef.current) {
@@ -1,21 +1,16 @@
"use client" "use client"
import getSectionId from "@/utils/get-section-id"
import type { OpenAPIV3 } from "openapi-types" import type { OpenAPIV3 } from "openapi-types"
import useSWR from "swr"
import type { Operation, PathsObject } from "@/types/openapi" import type { Operation, PathsObject } from "@/types/openapi"
import { useSidebar, swrFetcher } from "docs-ui" import { useSidebar } from "docs-ui"
import { Fragment, useEffect, useMemo } from "react" import { Fragment, useEffect } from "react"
import dynamic from "next/dynamic" import dynamic from "next/dynamic"
import type { TagOperationProps } from "../Operation" import type { TagOperationProps } from "../Operation"
import { useArea } from "@/providers/area"
import clsx from "clsx" import clsx from "clsx"
import { useBaseSpecs } from "@/providers/base-specs"
import getTagChildSidebarItems from "@/utils/get-tag-child-sidebar-items" import getTagChildSidebarItems from "@/utils/get-tag-child-sidebar-items"
import { useLoading } from "@/providers/loading" import { useLoading } from "@/providers/loading"
import DividedLoading from "@/components/DividedLoading" import DividedLoading from "@/components/DividedLoading"
import { SidebarItemSections, SidebarItem, SidebarItemCategory } from "types" import { SidebarItemSections, SidebarItem, SidebarItemCategory } from "types"
import basePathUrl from "../../../utils/base-path-url"
const TagOperation = dynamic<TagOperationProps>( const TagOperation = dynamic<TagOperationProps>(
async () => import("../Operation") async () => import("../Operation")
@@ -23,35 +18,12 @@ const TagOperation = dynamic<TagOperationProps>(
export type TagPathsProps = { export type TagPathsProps = {
tag: OpenAPIV3.TagObject tag: OpenAPIV3.TagObject
paths: PathsObject
} & React.HTMLAttributes<HTMLDivElement> } & React.HTMLAttributes<HTMLDivElement>
const TagPaths = ({ tag, className }: TagPathsProps) => { const TagPaths = ({ tag, className, paths }: TagPathsProps) => {
const tagSlugName = useMemo(() => getSectionId([tag.name]), [tag])
const { area } = useArea()
const { items, addItems, findItemInSection } = useSidebar() const { items, addItems, findItemInSection } = useSidebar()
const { baseSpecs } = useBaseSpecs()
const { loading } = useLoading() 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(() => { useEffect(() => {
if (paths) { if (paths) {
@@ -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 { SchemaObject } from "../../../../types/openapi"
import TagOperationParameters from "../../Operation/Parameters" import TagOperationParameters from "../../Operation/Parameters"
import { import {
Badge, Badge,
CodeBlock, CodeBlock,
isElmWindow, isElmWindow,
useIsBrowser,
useScrollController, useScrollController,
useSidebar, useSidebar,
} from "docs-ui" } from "docs-ui"
@@ -16,7 +19,6 @@ import useSchemaExample from "../../../../hooks/use-schema-example"
import { InView } from "react-intersection-observer" import { InView } from "react-intersection-observer"
import checkElementInViewport from "../../../../utils/check-element-in-viewport" import checkElementInViewport from "../../../../utils/check-element-in-viewport"
import { singular } from "pluralize" import { singular } from "pluralize"
import useResizeObserver from "@react-hook/resize-observer"
import clsx from "clsx" import clsx from "clsx"
export type TagSectionSchemaProps = { export type TagSectionSchemaProps = {
@@ -26,7 +28,6 @@ export type TagSectionSchemaProps = {
const TagSectionSchema = ({ schema, tagName }: TagSectionSchemaProps) => { const TagSectionSchema = ({ schema, tagName }: TagSectionSchemaProps) => {
const paramsRef = useRef<HTMLDivElement>(null) const paramsRef = useRef<HTMLDivElement>(null)
const [maxCodeHeight, setMaxCodeHeight] = useState(0)
const { addItems, setActivePath, activePath } = useSidebar() const { addItems, setActivePath, activePath } = useSidebar()
const tagSlugName = useMemo(() => getSectionId([tagName]), [tagName]) const tagSlugName = useMemo(() => getSectionId([tagName]), [tagName])
const formattedName = useMemo( const formattedName = useMemo(
@@ -43,14 +44,16 @@ const TagSectionSchema = ({ schema, tagName }: TagSectionSchemaProps) => {
skipNonRequired: false, skipNonRequired: false,
}, },
}) })
useResizeObserver(paramsRef, () => { const { isBrowser } = useIsBrowser()
setMaxCodeHeight(paramsRef.current?.clientHeight || 0)
})
const { scrollableElement, scrollToElement } = useScrollController() const { scrollableElement, scrollToElement } = useScrollController()
const root = useMemo(() => { const root = useMemo(() => {
if (!isBrowser) {
return
}
return isElmWindow(scrollableElement) ? document.body : scrollableElement return isElmWindow(scrollableElement) ? document.body : scrollableElement
}, [scrollableElement]) }, [isBrowser, scrollableElement])
useEffect(() => { useEffect(() => {
addItems( addItems(
@@ -77,18 +80,26 @@ const TagSectionSchema = ({ schema, tagName }: TagSectionSchemaProps) => {
}, [formattedName]) }, [formattedName])
useEffect(() => { useEffect(() => {
if (!isBrowser) {
return
}
if (schemaSlug === (activePath || location.hash.replace("#", ""))) { if (schemaSlug === (activePath || location.hash.replace("#", ""))) {
const elm = document.getElementById(schemaSlug) as HTMLElement const elm = document.getElementById(schemaSlug) as HTMLElement
if (!checkElementInViewport(elm, 0)) { if (!checkElementInViewport(elm, 0)) {
scrollToElement(elm) scrollToElement(elm)
} }
} }
}, [activePath, schemaSlug]) }, [activePath, schemaSlug, isBrowser])
const handleViewChange = ( const handleViewChange = (
inView: boolean, inView: boolean,
entry: IntersectionObserverEntry entry: IntersectionObserverEntry
) => { ) => {
if (!isBrowser) {
return
}
const section = entry.target const section = entry.target
if ( if (
@@ -106,7 +117,7 @@ const TagSectionSchema = ({ schema, tagName }: TagSectionSchemaProps) => {
<InView <InView
as="div" as="div"
id={schemaSlug} id={schemaSlug}
initialInView={false} initialInView={true}
onChange={handleViewChange} onChange={handleViewChange}
root={root} root={root}
threshold={0.1} threshold={0.1}
@@ -128,10 +139,9 @@ const TagSectionSchema = ({ schema, tagName }: TagSectionSchemaProps) => {
source={examples[0].content} source={examples[0].content}
lang="json" lang="json"
title={`The ${formattedName} Object`} title={`The ${formattedName} Object`}
className={clsx(maxCodeHeight && "overflow-auto")} className={clsx("overflow-auto")}
style={{ style={{
// remove padding + extra space maxHeight: "100vh",
maxHeight: maxCodeHeight ? maxCodeHeight - 212 : "unset",
}} }}
/> />
)} )}
@@ -6,13 +6,13 @@ import { useEffect, useMemo, useState } from "react"
import { import {
isElmWindow, isElmWindow,
swrFetcher, swrFetcher,
useIsBrowser,
useScrollController, useScrollController,
useSidebar, useSidebar,
} from "docs-ui" } from "docs-ui"
import dynamic from "next/dynamic" import dynamic from "next/dynamic"
import type { SectionProps } from "../../Section" import type { SectionProps } from "../../Section"
import type { MDXContentClientProps } from "../../MDXContent/Client" import type { MDXContentClientProps } from "../../MDXContent/Client"
import TagPaths from "../Paths"
import DividedLayout from "@/layouts/Divided" import DividedLayout from "@/layouts/Divided"
import LoadingProvider from "@/providers/loading" import LoadingProvider from "@/providers/loading"
import SectionContainer from "../../Section/Container" import SectionContainer from "../../Section/Container"
@@ -22,11 +22,12 @@ import clsx from "clsx"
import { Feedback, Loading, Link } from "docs-ui" import { Feedback, Loading, Link } from "docs-ui"
import { usePathname, useRouter } from "next/navigation" import { usePathname, useRouter } from "next/navigation"
import formatReportLink from "@/utils/format-report-link" import formatReportLink from "@/utils/format-report-link"
import { SchemaObject, TagObject } from "@/types/openapi" import { PathsObject, SchemaObject, TagObject } from "@/types/openapi"
import useSWR from "swr"
import { TagSectionSchemaProps } from "./Schema" import { TagSectionSchemaProps } from "./Schema"
import basePathUrl from "../../../utils/base-path-url"
import checkElementInViewport from "../../../utils/check-element-in-viewport" 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 = { export type TagSectionProps = {
tag: TagObject tag: TagObject
@@ -47,39 +48,30 @@ const MDXContentClient = dynamic<MDXContentClientProps>(
} }
) as React.FC<MDXContentClientProps> ) as React.FC<MDXContentClientProps>
const TagSection = ({ tag }: TagSectionProps) => { const TagSectionComponent = ({ tag }: TagSectionProps) => {
const { activePath, setActivePath } = useSidebar() const { activePath, setActivePath } = useSidebar()
const router = useRouter() const router = useRouter()
const [loadPaths, setLoadPaths] = useState(false) const [loadData, setLoadData] = useState(false)
const slugTagName = useMemo(() => getSectionId([tag.name]), [tag]) const slugTagName = useMemo(() => getSectionId([tag.name]), [tag])
const { area } = useArea() const { area } = useArea()
const pathname = usePathname() const pathname = usePathname()
const { scrollableElement, scrollToTop } = useScrollController() const { scrollableElement, scrollToTop } = useScrollController()
const { data } = useSWR<{ const { isBrowser } = useIsBrowser()
schema: SchemaObject
}>(
tag["x-associatedSchema"]
? basePathUrl(
`/api/schema?name=${tag["x-associatedSchema"].$ref}&area=${area}`
)
: null,
swrFetcher,
{
errorRetryInterval: 2000,
}
)
const associatedSchema = data?.schema
const root = useMemo(() => { const root = useMemo(() => {
if (!isBrowser) {
return
}
return isElmWindow(scrollableElement) ? document.body : scrollableElement return isElmWindow(scrollableElement) ? document.body : scrollableElement
}, [scrollableElement]) }, [scrollableElement, isBrowser])
const { ref } = useInView({ const { ref, inView } = useInView({
threshold: 0.8, threshold: 0.8,
rootMargin: `112px 0px 112px 0px`, rootMargin: `112px 0px 112px 0px`,
root, root,
onChange: (inView) => { onChange: (inView) => {
if (inView && !loadPaths) { if (inView && !loadData) {
setLoadPaths(true) setLoadData(true)
} }
if (inView) { if (inView) {
// ensure that the hash link doesn't change if it links to an inner path // 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(() => { useEffect(() => {
if (!isBrowser) {
return
}
if (activePath && activePath.includes(slugTagName)) { if (activePath && activePath.includes(slugTagName)) {
const tagName = activePath.split("_") const tagName = activePath.split("_")
if (tagName.length === 1 && tagName[0] === slugTagName) { if (tagName.length === 1 && tagName[0] === slugTagName) {
@@ -110,18 +130,18 @@ const TagSection = ({ tag }: TagSectionProps) => {
) )
} }
} else if (tagName.length > 1 && tagName[0] === slugTagName) { } else if (tagName.length > 1 && tagName[0] === slugTagName) {
setLoadPaths(true) setLoadData(true)
} }
} }
}, [slugTagName, activePath]) }, [slugTagName, activePath, isBrowser])
return ( return (
<div <div
className={clsx("min-h-screen", !loadPaths && "relative")} className={clsx("min-h-screen", !loadData && "relative")}
id={slugTagName} id={slugTagName}
ref={ref}
> >
<DividedLayout <DividedLayout
ref={ref}
mainContent={ mainContent={
<SectionContainer noDivider={true}> <SectionContainer noDivider={true}>
<h2>{tag.name}</h2> <h2>{tag.name}</h2>
@@ -159,17 +179,17 @@ const TagSection = ({ tag }: TagSectionProps) => {
} }
codeContent={<></>} codeContent={<></>}
/> />
{associatedSchema && ( {schemaData && (
<TagSectionSchema schema={associatedSchema} tagName={tag.name} /> <TagSectionSchema schema={schemaData.schema} tagName={tag.name} />
)} )}
{loadPaths && ( {loadData && pathsData && (
<LoadingProvider initialLoading={true}> <LoadingProvider initialLoading={true}>
<TagPaths tag={tag} /> <TagPaths tag={tag} paths={pathsData.paths} />
</LoadingProvider> </LoadingProvider>
)} )}
{!loadPaths && <SectionDivider className="lg:!-left-1" />} {!loadData && <SectionDivider className="lg:!-left-1" />}
</div> </div>
) )
} }
export default TagSection export default TagSectionComponent
@@ -1,120 +1,20 @@
"use client" import { OpenAPIV3 } from "openapi-types"
import { TagSectionProps } from "./Section"
import type { OpenAPIV3 } from "openapi-types"
import { useEffect, useState } from "react"
import useSWR from "swr"
import { useBaseSpecs } from "@/providers/base-specs"
import dynamic from "next/dynamic" 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<TagSectionProps>( const TagSection = dynamic<TagSectionProps>(
async () => import("./Section") async () => import("./Section")
) as React.FC<TagSectionProps> ) as React.FC<TagSectionProps>
export type TagsProps = React.HTMLAttributes<HTMLDivElement> type TagsProps = {
tags?: OpenAPIV3.TagObject[]
function getCurrentTag() {
return typeof location !== "undefined"
? location.hash.replace("#", "").split("_")[0]
: ""
} }
const Tags = () => { const Tags = ({ tags }: TagsProps) => {
const [tags, setTags] = useState<OpenAPIV3.TagObject[]>([])
const [loadData, setLoadData] = useState<boolean>(false)
const [expand, setExpand] = useState<string>("")
const { baseSpecs, setBaseSpecs } = useBaseSpecs()
const { activePath, addItems, setActivePath } = useSidebar()
const { area, prevArea } = useArea()
const router = useRouter()
const { data } = useSWR<ExpandedDocument>(
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])
return ( return (
<> <>
{tags.map((tag, index) => ( {tags?.map((tag) => (
<TagSection tag={tag} key={index} /> <TagSection tag={tag} key={tag.name} />
))} ))}
</> </>
) )
+20
View File
@@ -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)
}
}
+70 -12
View File
@@ -1,28 +1,34 @@
"use client" "use client"
import { ExpandedDocument, SecuritySchemeObject } from "@/types/openapi" 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 = { type BaseSpecsContextType = {
baseSpecs: ExpandedDocument | null baseSpecs: ExpandedDocument | undefined
setBaseSpecs: React.Dispatch<React.SetStateAction<ExpandedDocument | null>>
getSecuritySchema: (securityName: string) => SecuritySchemeObject | null getSecuritySchema: (securityName: string) => SecuritySchemeObject | null
} }
const BaseSpecsContext = createContext<BaseSpecsContextType | null>(null) const BaseSpecsContext = createContext<BaseSpecsContextType | null>(null)
type BaseSpecsProviderProps = { type BaseSpecsProviderProps = {
initialSpecs?: ExpandedDocument | null baseSpecs: ExpandedDocument | undefined
children?: ReactNode children?: ReactNode
} }
const BaseSpecsProvider = ({ const BaseSpecsProvider = ({ children, baseSpecs }: BaseSpecsProviderProps) => {
children, const router = useRouter()
initialSpecs = null, const { activePath, addItems, setActivePath, resetItems } = useSidebar()
}: BaseSpecsProviderProps) => {
const [baseSpecs, setBaseSpecs] = useState<ExpandedDocument | null>(
initialSpecs
)
const getSecuritySchema = ( const getSecuritySchema = (
securityName: string securityName: string
@@ -43,11 +49,63 @@ const BaseSpecsProvider = ({
return null 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 ( return (
<BaseSpecsContext.Provider <BaseSpecsContext.Provider
value={{ value={{
baseSpecs, baseSpecs,
setBaseSpecs,
getSecuritySchema, getSecuritySchema,
}} }}
> >
@@ -1,12 +1,9 @@
"use client"
import { import {
AnalyticsProvider, AnalyticsProvider,
PageLoadingProvider, PageLoadingProvider,
ScrollControllerProvider, ScrollControllerProvider,
SiteConfigProvider, SiteConfigProvider,
} from "docs-ui" } from "docs-ui"
import BaseSpecsProvider from "./base-specs"
import SidebarProvider from "./sidebar" import SidebarProvider from "./sidebar"
import SearchProvider from "./search" import SearchProvider from "./search"
import { config } from "../config" import { config } from "../config"
@@ -21,7 +18,6 @@ const Providers = ({ children }: ProvidersProps) => {
<AnalyticsProvider writeKey={process.env.NEXT_PUBLIC_SEGMENT_API_KEY}> <AnalyticsProvider writeKey={process.env.NEXT_PUBLIC_SEGMENT_API_KEY}>
<SiteConfigProvider config={config}> <SiteConfigProvider config={config}>
<PageLoadingProvider> <PageLoadingProvider>
<BaseSpecsProvider>
<ScrollControllerProvider scrollableSelector="#main"> <ScrollControllerProvider scrollableSelector="#main">
<SidebarProvider> <SidebarProvider>
<MainNavProvider> <MainNavProvider>
@@ -29,7 +25,6 @@ const Providers = ({ children }: ProvidersProps) => {
</MainNavProvider> </MainNavProvider>
</SidebarProvider> </SidebarProvider>
</ScrollControllerProvider> </ScrollControllerProvider>
</BaseSpecsProvider>
</PageLoadingProvider> </PageLoadingProvider>
</SiteConfigProvider> </SiteConfigProvider>
</AnalyticsProvider> </AnalyticsProvider>
@@ -3,12 +3,9 @@
import { import {
SidebarProvider as UiSidebarProvider, SidebarProvider as UiSidebarProvider,
usePageLoading, usePageLoading,
usePrevious,
useScrollController, useScrollController,
} from "docs-ui" } from "docs-ui"
import { config } from "../config" import { config } from "../config"
import { useCallback } from "react"
import { usePathname } from "next/navigation"
type SidebarProviderProps = { type SidebarProviderProps = {
children?: React.ReactNode children?: React.ReactNode
@@ -17,13 +14,6 @@ type SidebarProviderProps = {
const SidebarProvider = ({ children }: SidebarProviderProps) => { const SidebarProvider = ({ children }: SidebarProviderProps) => {
const { isLoading, setIsLoading } = usePageLoading() const { isLoading, setIsLoading } = usePageLoading()
const { scrollableElement } = useScrollController() const { scrollableElement } = useScrollController()
const pathname = usePathname()
const prevPathName = usePrevious(pathname)
const resetOnCondition = useCallback(
() => prevPathName !== undefined && pathname !== prevPathName,
[pathname, prevPathName]
)
return ( return (
<UiSidebarProvider <UiSidebarProvider
@@ -32,7 +22,6 @@ const SidebarProvider = ({ children }: SidebarProviderProps) => {
shouldHandleHashChange={true} shouldHandleHashChange={true}
scrollableElement={scrollableElement} scrollableElement={scrollableElement}
initialItems={config.sidebar} initialItems={config.sidebar}
resetOnCondition={resetOnCondition}
persistState={false} persistState={false}
projectName="api" projectName="api"
> >
@@ -53,6 +53,9 @@ export default async function dereference({
canParse: /.*/, canParse: /.*/,
}, },
}, },
dereference: {
circular: "ignore",
},
})) as unknown as Document })) as unknown as Document
return document return document
@@ -5,8 +5,9 @@ import type { Operation, Document, ParsedPathItemObject } from "@/types/openapi"
import readSpecDocument from "./read-spec-document" import readSpecDocument from "./read-spec-document"
import getSectionId from "./get-section-id" import getSectionId from "./get-section-id"
import dereference from "./dereference" import dereference from "./dereference"
import { unstable_cache } from "next/cache"
export default async function getPathsOfTag( async function getPathsOfTag_(
tagName: string, tagName: string,
area: string area: string
): Promise<Document> { ): Promise<Document> {
@@ -47,3 +48,13 @@ export default async function getPathsOfTag(
paths: documents, paths: documents,
}) })
} }
const getPathsOfTag = unstable_cache(
async (tagName: string, area: string) => getPathsOfTag_(tagName, area),
["tag-paths"],
{
revalidate: 3600,
}
)
export default getPathsOfTag
@@ -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
@@ -1,5 +1,3 @@
"use client"
import React from "react" import React from "react"
import { import {
BrowserProvider, BrowserProvider,
@@ -134,14 +134,22 @@ export const Sidebar = ({
{!sidebarItems.default.length && !staticSidebarItems && ( {!sidebarItems.default.length && !staticSidebarItems && (
<Loading className="px-0" /> <Loading className="px-0" />
)} )}
{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 (
<SidebarItem <SidebarItem
item={item} item={item}
key={index} key={itemKey}
expandItems={expandItems} expandItems={expandItems}
hasNextItems={index !== sidebarItems.default.length - 1} hasNextItems={index !== sidebarItems.default.length - 1}
/> />
))} )
})}
</div> </div>
</div> </div>
</CSSTransition> </CSSTransition>
@@ -40,7 +40,8 @@ export type SidebarContextType = {
setActivePath: (path: string | null) => void setActivePath: (path: string | null) => void
isLinkActive: (item: SidebarItem, checkChildren?: boolean) => boolean isLinkActive: (item: SidebarItem, checkChildren?: boolean) => boolean
isChildrenActive: (item: SidebarItemCategory) => boolean isChildrenActive: (item: SidebarItemCategory) => boolean
addItems: (item: SidebarItem[], options?: ActionOptionsType) => void addItems: (items: SidebarItem[], options?: ActionOptionsType) => void
removeItems: (items: SidebarItem[]) => void
findItemInSection: ( findItemInSection: (
section: SidebarItem[], section: SidebarItem[],
item: Partial<SidebarItem>, item: Partial<SidebarItem>,
@@ -87,6 +88,10 @@ export type ActionType =
type: "replace" type: "replace"
replacementItems: SidebarSectionItems replacementItems: SidebarSectionItems
} }
| {
type: "remove"
items: SidebarItem[]
}
type LinksMap = Map<string, SidebarItemLinkWithParent> type LinksMap = Map<string, SidebarItemLinkWithParent>
@@ -152,10 +157,46 @@ const getLinksMap = (
return map return map
} }
export const reducer = (state: SidebarSectionItems, actionData: ActionType) => { export const reducer = (
state: SidebarSectionItems,
actionData: ActionType
): SidebarSectionItems => {
if (actionData.type === "replace") { if (actionData.type === "replace") {
return actionData.replacementItems 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 const { type, options } = actionData
let { items } = actionData let { items } = actionData
@@ -306,6 +347,13 @@ export const SidebarProvider = ({
}) })
} }
const removeItems = (itemsToRemove: SidebarItem[]) => {
dispatch({
type: "remove",
items: itemsToRemove,
})
}
const isLinkActive = useCallback( const isLinkActive = useCallback(
(item: SidebarItem, checkChildren = false): boolean => { (item: SidebarItem, checkChildren = false): boolean => {
if (item.type !== "link") { if (item.type !== "link") {
@@ -516,6 +564,7 @@ export const SidebarProvider = ({
useEffect(() => { useEffect(() => {
if (resetOnCondition?.()) { if (resetOnCondition?.()) {
resetItems() resetItems()
setActivePath(null)
} }
}, [resetOnCondition, resetItems]) }, [resetOnCondition, resetItems])
@@ -575,6 +624,7 @@ export const SidebarProvider = ({
items, items,
currentItems, currentItems,
addItems, addItems,
removeItems,
activePath, activePath,
setActivePath, setActivePath,
isLinkActive: isLinkActive, isLinkActive: isLinkActive,