From 4d71ab27675de5aee5dd653980d371f8d66d708e Mon Sep 17 00:00:00 2001 From: Steven Serrata Date: Mon, 15 Jun 2026 16:26:06 -0500 Subject: [PATCH 1/2] fix(theme): vendor theme-common/internal usages ahead of Docusaurus v4 (#1140) Docusaurus v4 will remove the `@docusaurus/theme-common/internal` entry point and the back-compat shim added in facebook/docusaurus#11153. This package imported 13 symbols across 10 files from that path (CodeBlock utilities and Tabs primitives), each of which already started emitting ESModulesLinkingWarning in v3.8 user builds. Vendor the leaf utilities into `src/utils/` (codeBlockUtils, useCodeWordWrap, useMutationObserver, reactUtils, tabsUtils, scrollUtils) with MIT attribution to Meta, repoint the 10 source files at the local copies, and delete the `declare module "@docusaurus/theme-common/internal"` ambient shim so the compiler enforces no future regressions. Swizzle `@theme/Tabs` and `@theme/TabItem` so user-authored ``/`` in MDX share the same React context as our six OpenAPI tab variants (ApiTabs, MimeTabs, SchemaTabs, OperationTabs, DiscriminatorTabs, CodeTabs). Without the swizzles, our vendored TabsContext is a different object than Docusaurus's, so stock TabItem children would fail to find a provider at SSR. ScrollControllerProvider is auto-mounted inside our vendored TabsProvider so every tab consumer is self-sufficient. The peer-dep range on `@docusaurus/*` is unchanged; vendoring removes our coupling to the unstable entry point without affecting public API compatibility. Co-Authored-By: Claude Opus 4.7 --- .../src/theme-classic.d.ts | 96 ----- .../ApiCodeBlock/Container/index.tsx | 3 +- .../ApiCodeBlock/Content/String.tsx | 15 +- .../ApiExplorer/ApiCodeBlock/Line/index.tsx | 2 +- .../theme/ApiExplorer/ApiCodeBlock/index.tsx | 2 +- .../src/theme/ApiExplorer/CodeTabs/index.tsx | 10 +- .../src/theme/ApiTabs/index.tsx | 13 +- .../src/theme/DiscriminatorTabs/index.tsx | 11 +- .../src/theme/MimeTabs/index.tsx | 17 +- .../src/theme/OperationTabs/index.tsx | 9 +- .../src/theme/SchemaTabs/index.tsx | 11 +- .../src/theme/TabItem/index.tsx | 61 ++++ .../src/theme/TabItem/styles.module.css | 3 + .../src/theme/Tabs/index.tsx | 164 +++++++++ .../src/theme/Tabs/styles.module.css | 7 + .../src/utils/codeBlockUtils.ts | 296 ++++++++++++++++ .../src/utils/reactUtils.ts | 48 +++ .../src/utils/scrollUtils.tsx | 154 ++++++++ .../src/utils/tabsUtils.tsx | 329 ++++++++++++++++++ .../src/utils/useCodeWordWrap.ts | 110 ++++++ .../src/utils/useMutationObserver.ts | 41 +++ 21 files changed, 1263 insertions(+), 139 deletions(-) create mode 100644 packages/docusaurus-theme-openapi-docs/src/theme/TabItem/index.tsx create mode 100644 packages/docusaurus-theme-openapi-docs/src/theme/TabItem/styles.module.css create mode 100644 packages/docusaurus-theme-openapi-docs/src/theme/Tabs/index.tsx create mode 100644 packages/docusaurus-theme-openapi-docs/src/theme/Tabs/styles.module.css create mode 100644 packages/docusaurus-theme-openapi-docs/src/utils/codeBlockUtils.ts create mode 100644 packages/docusaurus-theme-openapi-docs/src/utils/reactUtils.ts create mode 100644 packages/docusaurus-theme-openapi-docs/src/utils/scrollUtils.tsx create mode 100644 packages/docusaurus-theme-openapi-docs/src/utils/tabsUtils.tsx create mode 100644 packages/docusaurus-theme-openapi-docs/src/utils/useCodeWordWrap.ts create mode 100644 packages/docusaurus-theme-openapi-docs/src/utils/useMutationObserver.ts diff --git a/packages/docusaurus-theme-openapi-docs/src/theme-classic.d.ts b/packages/docusaurus-theme-openapi-docs/src/theme-classic.d.ts index dc7853700..11ea09acc 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme-classic.d.ts +++ b/packages/docusaurus-theme-openapi-docs/src/theme-classic.d.ts @@ -6,99 +6,3 @@ * ========================================================================== */ /// - -declare module "@docusaurus/theme-common/internal" { - import { CSSProperties, ReactNode, RefObject } from "react"; - - import type { PropDocContent } from "@docusaurus/plugin-content-docs"; - import { MagicCommentConfig } from "@docusaurus/theme-common/lib/utils/codeBlockUtils"; - import { - TabsProps as ITabsProps, - TabValue, - } from "@docusaurus/theme-common/lib/utils/tabsUtils"; - import { Props as ICodeBlockProps } from "@theme/CodeBlock"; - import { Props as ICopyButtonProps } from "@theme/CodeBlock/CopyButton"; - import { Props as ILineProps } from "@theme/CodeBlock/Line"; - import { PrismTheme } from "prism-react-renderer"; - - export interface TabItemProps { - readonly children: ReactNode; - readonly value: string; - readonly default?: boolean; - readonly label?: string; - readonly className?: string; - readonly attributes?: { [key: string]: unknown }; - } - - export interface TabProps extends ITabsProps { - length?: number; - } - - export interface CopyButtonProps extends ICopyButtonProps {} - export interface LineProps extends ILineProps {} - export interface CodeBlockProps extends ICodeBlockProps {} - - export function usePrismTheme(): PrismTheme; - - export function sanitizeTabsChildren(children: TabProps["children"]); - - export function getPrismCssVariables(prismTheme: PrismTheme): CSSProperties; - - export function parseCodeBlockTitle(metastring?: string): string; - - export function parseLanguage(className: string): string | undefined; - - export function containsLineNumbers(metastring?: string): boolean; - - export function useScrollPositionBlocker(): { - blockElementScrollPositionUntilNextRender: (el: HTMLElement) => void; - }; - - export function DocProvider({ - children, - content, - }: { - children: ReactNode; - content: PropDocContent; - }); - - export function useTabsContextValue(props: TabProps): { - selectedValue: string; - selectValue: (value: string) => void; - tabValues: readonly TabValue[]; - lazy: boolean; - block: boolean; - }; - - export function useTabs(): { - selectedValue: string; - selectValue: (value: string) => void; - tabValues: readonly TabValue[]; - lazy: boolean; - block: boolean; - }; - - export function TabsProvider(props: { - children: ReactNode; - value: ReturnType; - }): ReactNode; - - export function parseLines( - content: string, - options: { - metastring: string | undefined; - language: string | undefined; - magicComments: MagicCommentConfig[]; - } - ): { - lineClassNames: { [lineIndex: number]: string[] }; - code: string; - }; - - export function useCodeWordWrap(): { - readonly codeBlockRef: RefObject; - readonly isEnabled: boolean; - readonly isCodeScrollable: boolean; - readonly toggle: () => void; - }; -} diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/ApiCodeBlock/Container/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/ApiCodeBlock/Container/index.tsx index 68f8dffb2..5698a420a 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/ApiCodeBlock/Container/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/ApiCodeBlock/Container/index.tsx @@ -8,9 +8,10 @@ import React, { ComponentProps } from "react"; import { ThemeClassNames, usePrismTheme } from "@docusaurus/theme-common"; -import { getPrismCssVariables } from "@docusaurus/theme-common/internal"; import clsx from "clsx"; +import { getPrismCssVariables } from "../../../../utils/codeBlockUtils"; + export default function CodeBlockContainer({ as: As, ...props diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/ApiCodeBlock/Content/String.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/ApiCodeBlock/Content/String.tsx index 5c0c1791c..e34fb456c 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/ApiCodeBlock/Content/String.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/ApiCodeBlock/Content/String.tsx @@ -8,13 +8,6 @@ import React from "react"; import { useThemeConfig, usePrismTheme } from "@docusaurus/theme-common"; -import { - parseCodeBlockTitle, - parseLanguage, - parseLines, - containsLineNumbers, - useCodeWordWrap, -} from "@docusaurus/theme-common/internal"; import Container from "@theme/ApiExplorer/ApiCodeBlock/Container"; import CopyButton from "@theme/ApiExplorer/ApiCodeBlock/CopyButton"; import ExpandButton from "@theme/ApiExplorer/ApiCodeBlock/ExpandButton"; @@ -24,6 +17,14 @@ import type { Props } from "@theme/CodeBlock/Content/String"; import clsx from "clsx"; import { Highlight, Language } from "prism-react-renderer"; +import { + containsLineNumbers, + parseCodeBlockTitle, + parseLanguage, + parseLines, +} from "../../../../utils/codeBlockUtils"; +import { useCodeWordWrap } from "../../../../utils/useCodeWordWrap"; + export default function CodeBlockString({ children, className: blockClassName = "", diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/ApiCodeBlock/Line/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/ApiCodeBlock/Line/index.tsx index 94cff38db..e7cc594b6 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/ApiCodeBlock/Line/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/ApiCodeBlock/Line/index.tsx @@ -7,7 +7,7 @@ import React from "react"; -import { LineProps } from "@docusaurus/theme-common/internal"; +import type { Props as LineProps } from "@theme/CodeBlock/Line"; import clsx from "clsx"; export default function CodeBlockLine({ diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/ApiCodeBlock/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/ApiCodeBlock/index.tsx index b09c2dedd..cec02e9e8 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/ApiCodeBlock/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/ApiCodeBlock/index.tsx @@ -7,8 +7,8 @@ import React, { isValidElement, ReactNode } from "react"; -import { CodeBlockProps } from "@docusaurus/theme-common/internal"; import useIsBrowser from "@docusaurus/useIsBrowser"; +import type { Props as CodeBlockProps } from "@theme/CodeBlock"; import ElementContent from "@theme/ApiExplorer/ApiCodeBlock/Content/Element"; import StringContent from "@theme/ApiExplorer/ApiCodeBlock/Content/String"; diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/CodeTabs/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/CodeTabs/index.tsx index 513361ced..046db9944 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/CodeTabs/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/CodeTabs/index.tsx @@ -7,17 +7,17 @@ import React, { cloneElement, ReactElement, useEffect, useRef } from "react"; +import useIsBrowser from "@docusaurus/useIsBrowser"; +import clsx from "clsx"; + +import { useScrollPositionBlocker } from "../../../utils/scrollUtils"; import { sanitizeTabsChildren, type TabItemProps, type TabProps, TabsProvider, - useScrollPositionBlocker, useTabsContextValue, -} from "@docusaurus/theme-common/internal"; -import useIsBrowser from "@docusaurus/useIsBrowser"; -import clsx from "clsx"; - +} from "../../../utils/tabsUtils"; import { Language } from "../CodeSnippets/code-snippets-types"; export interface Props { diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/ApiTabs/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/ApiTabs/index.tsx index 86fdd78c0..57f0d9ae4 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/ApiTabs/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/ApiTabs/index.tsx @@ -13,18 +13,19 @@ import React, { ReactElement, } from "react"; +import { translate } from "@docusaurus/Translate"; +import useIsBrowser from "@docusaurus/useIsBrowser"; +import Heading from "@theme/Heading"; +import clsx from "clsx"; + +import { useScrollPositionBlocker } from "../../utils/scrollUtils"; import { sanitizeTabsChildren, type TabItemProps, TabProps, TabsProvider, - useScrollPositionBlocker, useTabsContextValue, -} from "@docusaurus/theme-common/internal"; -import { translate } from "@docusaurus/Translate"; -import useIsBrowser from "@docusaurus/useIsBrowser"; -import Heading from "@theme/Heading"; -import clsx from "clsx"; +} from "../../utils/tabsUtils"; export interface TabListProps extends TabProps { label: string; diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/DiscriminatorTabs/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/DiscriminatorTabs/index.tsx index a4108e548..2be7cf1a4 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/DiscriminatorTabs/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/DiscriminatorTabs/index.tsx @@ -13,17 +13,18 @@ import React, { ReactElement, } from "react"; +import useIsBrowser from "@docusaurus/useIsBrowser"; +import clsx from "clsx"; +import flatten from "lodash/flatten"; + +import { useScrollPositionBlocker } from "../../utils/scrollUtils"; import { sanitizeTabsChildren, type TabItemProps, TabProps, TabsProvider, - useScrollPositionBlocker, useTabsContextValue, -} from "@docusaurus/theme-common/internal"; -import useIsBrowser from "@docusaurus/useIsBrowser"; -import clsx from "clsx"; -import flatten from "lodash/flatten"; +} from "../../utils/tabsUtils"; function TabList({ className, diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/MimeTabs/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/MimeTabs/index.tsx index 74841b12b..ac07c133d 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/MimeTabs/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/MimeTabs/index.tsx @@ -13,14 +13,6 @@ import React, { ReactElement, } from "react"; -import { - sanitizeTabsChildren, - type TabItemProps, - TabProps, - TabsProvider, - useScrollPositionBlocker, - useTabsContextValue, -} from "@docusaurus/theme-common/internal"; import useIsBrowser from "@docusaurus/useIsBrowser"; import { setAccept } from "@theme/ApiExplorer/Accept/slice"; import { setContentType } from "@theme/ApiExplorer/ContentType/slice"; @@ -28,6 +20,15 @@ import { useTypedDispatch, useTypedSelector } from "@theme/ApiItem/hooks"; import { RootState } from "@theme/ApiItem/store"; import clsx from "clsx"; +import { useScrollPositionBlocker } from "../../utils/scrollUtils"; +import { + sanitizeTabsChildren, + type TabItemProps, + TabProps, + TabsProvider, + useTabsContextValue, +} from "../../utils/tabsUtils"; + export interface Props { schemaType: any; } diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/OperationTabs/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/OperationTabs/index.tsx index 0c149d668..f7eb65b5d 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/OperationTabs/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/OperationTabs/index.tsx @@ -13,16 +13,17 @@ import React, { ReactElement, } from "react"; +import useIsBrowser from "@docusaurus/useIsBrowser"; +import clsx from "clsx"; + +import { useScrollPositionBlocker } from "../../utils/scrollUtils"; import { sanitizeTabsChildren, type TabItemProps, TabProps, TabsProvider, - useScrollPositionBlocker, useTabsContextValue, -} from "@docusaurus/theme-common/internal"; -import useIsBrowser from "@docusaurus/useIsBrowser"; -import clsx from "clsx"; +} from "../../utils/tabsUtils"; function TabList({ className, diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/SchemaTabs/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaTabs/index.tsx index 85de86472..0ca2bc97a 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/SchemaTabs/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaTabs/index.tsx @@ -14,17 +14,18 @@ import React, { LegacyRef, } from "react"; +import useIsBrowser from "@docusaurus/useIsBrowser"; +import clsx from "clsx"; +import flatten from "lodash/flatten"; + +import { useScrollPositionBlocker } from "../../utils/scrollUtils"; import { sanitizeTabsChildren, type TabItemProps, TabProps, TabsProvider, - useScrollPositionBlocker, useTabsContextValue, -} from "@docusaurus/theme-common/internal"; -import useIsBrowser from "@docusaurus/useIsBrowser"; -import clsx from "clsx"; -import flatten from "lodash/flatten"; +} from "../../utils/tabsUtils"; export interface SchemaTabsProps extends TabProps { /** diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/TabItem/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/TabItem/index.tsx new file mode 100644 index 000000000..93d5c5237 --- /dev/null +++ b/packages/docusaurus-theme-openapi-docs/src/theme/TabItem/index.tsx @@ -0,0 +1,61 @@ +/* ============================================================================ + * Portions Copyright (c) Meta Platforms, Inc. and affiliates. + * Portions Copyright (c) Palo Alto Networks + * + * Swizzled from @docusaurus/theme-classic/src/theme/TabItem/index.tsx (MIT). + * Re-points useTabs to our vendored tabsUtils so that reads the same + * context our swizzled and OpenAPI tab variants (ApiTabs, MimeTabs, + * SchemaTabs, etc.) provide. See: + * https://github.com/PaloAltoNetworks/docusaurus-openapi-docs/issues/1140 + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * ========================================================================== */ + +import React, { type ReactNode } from "react"; + +import clsx from "clsx"; + +import { type TabItemProps, useTabs } from "../../utils/tabsUtils"; + +type Props = TabItemProps; +import styles from "./styles.module.css"; + +function TabItemPanel({ + children, + className, + hidden, +}: { + children: ReactNode; + className?: string; + hidden?: boolean; +}) { + return ( + + ); +} + +export default function TabItem({ + children, + className, + value, +}: Props): ReactNode { + const { selectedValue, lazy } = useTabs(); + const isSelected = value === selectedValue; + + if (!isSelected && lazy) { + return null; + } + + return ( + + ); +} diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/TabItem/styles.module.css b/packages/docusaurus-theme-openapi-docs/src/theme/TabItem/styles.module.css new file mode 100644 index 000000000..f448b7fcd --- /dev/null +++ b/packages/docusaurus-theme-openapi-docs/src/theme/TabItem/styles.module.css @@ -0,0 +1,3 @@ +.tabItem > *:last-child { + margin-bottom: 0; +} diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/Tabs/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/Tabs/index.tsx new file mode 100644 index 000000000..7712e0ee9 --- /dev/null +++ b/packages/docusaurus-theme-openapi-docs/src/theme/Tabs/index.tsx @@ -0,0 +1,164 @@ +/* ============================================================================ + * Portions Copyright (c) Meta Platforms, Inc. and affiliates. + * Portions Copyright (c) Palo Alto Networks + * + * Swizzled from @docusaurus/theme-classic/src/theme/Tabs/index.tsx (MIT). + * Re-points the internal hooks (useTabs, useTabsContextValue, etc.) to our + * vendored tabsUtils so that the entire / pair runs through a + * single React context owned by this package. See: + * https://github.com/PaloAltoNetworks/docusaurus-openapi-docs/issues/1140 + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * ========================================================================== */ + +import React, { type ReactNode } from "react"; + +import { ThemeClassNames } from "@docusaurus/theme-common"; +import useIsBrowser from "@docusaurus/useIsBrowser"; +import clsx from "clsx"; + +import { useScrollPositionBlocker } from "../../utils/scrollUtils"; +import { + sanitizeTabsChildren, + type TabsProps, + TabsProvider, + useTabs, + useTabsContextValue, +} from "../../utils/tabsUtils"; +import styles from "./styles.module.css"; + +type Props = TabsProps; + +function TabList({ className }: { className?: string }) { + const { selectedValue, selectValue, tabValues, block } = useTabs(); + + const tabRefs: (HTMLLIElement | null)[] = []; + const { blockElementScrollPositionUntilNextRender } = + useScrollPositionBlocker(); + + const handleTabChange = ( + event: + | React.FocusEvent + | React.MouseEvent + | React.KeyboardEvent + ) => { + const newTab = event.currentTarget; + const newTabIndex = tabRefs.indexOf(newTab); + const newTabValue = tabValues[newTabIndex]!.value; + + if (newTabValue !== selectedValue) { + blockElementScrollPositionUntilNextRender(newTab); + selectValue(newTabValue); + } + }; + + const handleKeydown = (event: React.KeyboardEvent) => { + let focusElement: HTMLLIElement | null = null; + + switch (event.key) { + case "Enter": { + handleTabChange(event); + break; + } + case "ArrowRight": { + const nextTab = tabRefs.indexOf(event.currentTarget) + 1; + focusElement = tabRefs[nextTab] ?? tabRefs[0]!; + break; + } + case "ArrowLeft": { + const prevTab = tabRefs.indexOf(event.currentTarget) - 1; + focusElement = tabRefs[prevTab] ?? tabRefs[tabRefs.length - 1]!; + break; + } + default: + break; + } + + focusElement?.focus(); + }; + + return ( +
    + {tabValues.map(({ value, label, attributes }) => ( +
  • { + tabRefs.push(ref); + }} + onKeyDown={handleKeydown} + onClick={handleTabChange} + {...attributes} + className={clsx( + "tabs__item", + styles.tabItem, + attributes?.className as string, + { + "tabs__item--active": selectedValue === value, + } + )} + > + {label ?? value} +
  • + ))} +
+ ); +} + +function TabContent({ children }: { children: ReactNode }) { + return
{children}
; +} + +function TabsContainer({ + className, + children, +}: { + className?: string; + children: ReactNode; +}): ReactNode { + return ( +
+ + {children} +
+ ); +} + +export default function Tabs(props: Props): ReactNode { + const isBrowser = useIsBrowser(); + const value = useTabsContextValue(props); + return ( + + + {sanitizeTabsChildren(props.children)} + + + ); +} diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/Tabs/styles.module.css b/packages/docusaurus-theme-openapi-docs/src/theme/Tabs/styles.module.css new file mode 100644 index 000000000..0c79270e7 --- /dev/null +++ b/packages/docusaurus-theme-openapi-docs/src/theme/Tabs/styles.module.css @@ -0,0 +1,7 @@ +.tabList { + margin-bottom: var(--ifm-leading); +} + +.tabItem { + margin-top: 0 !important; +} diff --git a/packages/docusaurus-theme-openapi-docs/src/utils/codeBlockUtils.ts b/packages/docusaurus-theme-openapi-docs/src/utils/codeBlockUtils.ts new file mode 100644 index 000000000..df830d5dc --- /dev/null +++ b/packages/docusaurus-theme-openapi-docs/src/utils/codeBlockUtils.ts @@ -0,0 +1,296 @@ +/* ============================================================================ + * Portions Copyright (c) Meta Platforms, Inc. and affiliates. + * Portions Copyright (c) Palo Alto Networks + * + * Vendored subset of @docusaurus/theme-common/src/utils/codeBlockUtils.tsx (MIT) + * to remove the dependency on @docusaurus/theme-common/internal. + * See: https://github.com/PaloAltoNetworks/docusaurus-openapi-docs/issues/1140 + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * ========================================================================== */ + +import type { CSSProperties } from "react"; + +import rangeParser from "parse-numeric-range"; +import type { PrismTheme, PrismThemeEntry } from "prism-react-renderer"; + +const codeBlockTitleRegex = /title=(?["'])(?.*?)\1/; +const metastringLinesRangeRegex = /\{(?<range>[\d,-]+)\}/; + +const popularCommentPatterns = { + js: { start: "\\/\\/", end: "" }, + jsBlock: { start: "\\/\\*", end: "\\*\\/" }, + jsx: { start: "\\{\\s*\\/\\*", end: "\\*\\/\\s*\\}" }, + bash: { start: "#", end: "" }, + html: { start: "<!--", end: "-->" }, +} as const; + +const commentPatterns = { + ...popularCommentPatterns, + lua: { start: "--", end: "" }, + wasm: { start: "\\;\\;", end: "" }, + tex: { start: "%", end: "" }, + vb: { start: "['‘’]", end: "" }, + vbnet: { start: "(?:_\\s*)?['‘’]", end: "" }, + rem: { start: "[Rr][Ee][Mm]\\b", end: "" }, + f90: { start: "!", end: "" }, + ml: { start: "\\(\\*", end: "\\*\\)" }, + cobol: { start: "\\*>", end: "" }, +} as const; + +type CommentType = keyof typeof commentPatterns; +const popularCommentTypes = Object.keys( + popularCommentPatterns +) as CommentType[]; + +export type MagicCommentConfig = { + className: string; + line?: string; + block?: { start: string; end: string }; +}; + +function getCommentPattern( + languages: CommentType[], + magicCommentDirectives: MagicCommentConfig[] +) { + const commentPattern = languages + .map((lang) => { + const { start, end } = commentPatterns[lang]; + return `(?:${start}\\s*(${magicCommentDirectives + .flatMap((d) => [d.line, d.block?.start, d.block?.end].filter(Boolean)) + .join("|")})\\s*${end})`; + }) + .join("|"); + return new RegExp(`^\\s*(?:${commentPattern})\\s*$`); +} + +function getAllMagicCommentDirectiveStyles( + lang: string, + magicCommentDirectives: MagicCommentConfig[] +) { + switch (lang) { + case "js": + case "javascript": + case "ts": + case "typescript": + return getCommentPattern(["js", "jsBlock"], magicCommentDirectives); + + case "jsx": + case "tsx": + return getCommentPattern( + ["js", "jsBlock", "jsx"], + magicCommentDirectives + ); + + case "html": + return getCommentPattern( + ["js", "jsBlock", "html"], + magicCommentDirectives + ); + + case "python": + case "py": + case "bash": + return getCommentPattern(["bash"], magicCommentDirectives); + + case "markdown": + case "md": + return getCommentPattern(["html", "jsx", "bash"], magicCommentDirectives); + + case "tex": + case "latex": + case "matlab": + return getCommentPattern(["tex"], magicCommentDirectives); + + case "lua": + case "haskell": + return getCommentPattern(["lua"], magicCommentDirectives); + + case "sql": + return getCommentPattern(["lua", "jsBlock"], magicCommentDirectives); + + case "wasm": + return getCommentPattern(["wasm"], magicCommentDirectives); + + case "vb": + case "vba": + case "visual-basic": + return getCommentPattern(["vb", "rem"], magicCommentDirectives); + case "vbnet": + return getCommentPattern(["vbnet", "rem"], magicCommentDirectives); + + case "batch": + return getCommentPattern(["rem"], magicCommentDirectives); + + case "basic": + return getCommentPattern(["rem", "f90"], magicCommentDirectives); + + case "fsharp": + return getCommentPattern(["js", "ml"], magicCommentDirectives); + + case "ocaml": + case "sml": + return getCommentPattern(["ml"], magicCommentDirectives); + + case "fortran": + return getCommentPattern(["f90"], magicCommentDirectives); + + case "cobol": + return getCommentPattern(["cobol"], magicCommentDirectives); + + default: + return getCommentPattern(popularCommentTypes, magicCommentDirectives); + } +} + +export function parseCodeBlockTitle(metastring?: string): string { + return metastring?.match(codeBlockTitleRegex)?.groups!.title ?? ""; +} + +export function containsLineNumbers(metastring?: string): boolean { + return Boolean(metastring?.includes("showLineNumbers")); +} + +type ParseCodeLinesParam = { + metastring: string | undefined; + language: string | undefined; + magicComments: MagicCommentConfig[]; +}; + +type CodeLineClassNames = { [lineIndex: number]: string[] }; + +type ParsedCodeLines = { + code: string; + lineClassNames: CodeLineClassNames; +}; + +function parseCodeLinesFromMetastring( + code: string, + { metastring, magicComments }: ParseCodeLinesParam +): ParsedCodeLines | null { + if (metastring && metastringLinesRangeRegex.test(metastring)) { + const linesRange = metastring.match(metastringLinesRangeRegex)!.groups! + .range!; + if (magicComments.length === 0) { + throw new Error( + `A highlight range has been given in code block's metastring (\`\`\` ${metastring}), but no magic comment config is available. Docusaurus applies the first magic comment entry's className for metastring ranges.` + ); + } + const metastringRangeClassName = magicComments[0]!.className; + const lines = rangeParser(linesRange) + .filter((n) => n > 0) + .map((n) => [n - 1, [metastringRangeClassName]] as [number, string[]]); + return { lineClassNames: Object.fromEntries(lines), code }; + } + return null; +} + +function parseCodeLinesFromContent( + code: string, + params: ParseCodeLinesParam +): ParsedCodeLines { + const { language, magicComments } = params; + if (language === undefined) { + return { lineClassNames: {}, code }; + } + const directiveRegex = getAllMagicCommentDirectiveStyles( + language, + magicComments + ); + const lines = code.split(/\r?\n/); + const blocks = Object.fromEntries( + magicComments.map((d) => [d.className, { start: 0, range: "" }]) + ); + const lineToClassName: { [comment: string]: string } = Object.fromEntries( + magicComments + .filter((d) => d.line) + .map(({ className, line }) => [line!, className] as [string, string]) + ); + const blockStartToClassName: { [comment: string]: string } = + Object.fromEntries( + magicComments + .filter((d) => d.block) + .map(({ className, block }) => [block!.start, className]) + ); + const blockEndToClassName: { [comment: string]: string } = Object.fromEntries( + magicComments + .filter((d) => d.block) + .map(({ className, block }) => [block!.end, className]) + ); + for (let lineNumber = 0; lineNumber < lines.length; ) { + const line = lines[lineNumber]!; + const match = line.match(directiveRegex); + if (!match) { + lineNumber += 1; + continue; + } + const directive = match + .slice(1) + .find((item: string | undefined) => item !== undefined)!; + if (lineToClassName[directive]) { + blocks[lineToClassName[directive]!]!.range += `${lineNumber},`; + } else if (blockStartToClassName[directive]) { + blocks[blockStartToClassName[directive]!]!.start = lineNumber; + } else if (blockEndToClassName[directive]) { + blocks[blockEndToClassName[directive]!]!.range += `${ + blocks[blockEndToClassName[directive]!]!.start + }-${lineNumber - 1},`; + } + lines.splice(lineNumber, 1); + } + + const lineClassNames: { [lineIndex: number]: string[] } = {}; + Object.entries(blocks).forEach(([className, { range }]) => { + rangeParser(range).forEach((l) => { + lineClassNames[l] ??= []; + lineClassNames[l]!.push(className); + }); + }); + + return { code: lines.join("\n"), lineClassNames }; +} + +export function parseLines( + code: string, + params: ParseCodeLinesParam +): ParsedCodeLines { + const newCode = code.replace(/\r?\n$/, ""); + return ( + parseCodeLinesFromMetastring(newCode, { ...params }) ?? + parseCodeLinesFromContent(newCode, { ...params }) + ); +} + +function parseClassNameLanguage( + className: string | undefined +): string | undefined { + if (!className) { + return undefined; + } + const languageClassName = className + .split(" ") + .find((str) => str.startsWith("language-")); + return languageClassName?.replace(/language-/, ""); +} + +// Upstream renamed `parseLanguage` to `parseClassNameLanguage`; the back-compat +// shim in @docusaurus/theme-common/internal re-exports it under the old name. +// We keep the old name here since our call sites still use it. +export { parseClassNameLanguage as parseLanguage }; + +export function getPrismCssVariables(prismTheme: PrismTheme): CSSProperties { + const mapping: PrismThemeEntry = { + color: "--prism-color", + backgroundColor: "--prism-background-color", + }; + + const properties: { [key: string]: string } = {}; + Object.entries(prismTheme.plain).forEach(([key, value]) => { + const varName = mapping[key as keyof PrismThemeEntry]; + if (varName && typeof value === "string") { + properties[varName] = value; + } + }); + return properties; +} diff --git a/packages/docusaurus-theme-openapi-docs/src/utils/reactUtils.ts b/packages/docusaurus-theme-openapi-docs/src/utils/reactUtils.ts new file mode 100644 index 000000000..0a2edd37c --- /dev/null +++ b/packages/docusaurus-theme-openapi-docs/src/utils/reactUtils.ts @@ -0,0 +1,48 @@ +/* ============================================================================ + * Portions Copyright (c) Meta Platforms, Inc. and affiliates. + * Portions Copyright (c) Palo Alto Networks + * + * Vendored subset of @docusaurus/theme-common/src/utils/reactUtils.tsx (MIT) + * to remove the dependency on @docusaurus/theme-common/internal, which is + * scheduled for removal in Docusaurus v4. + * See: https://github.com/PaloAltoNetworks/docusaurus-openapi-docs/issues/1140 + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * ========================================================================== */ + +import { useCallback, useMemo, useRef } from "react"; + +import useIsomorphicLayoutEffect from "@docusaurus/useIsomorphicLayoutEffect"; + +export function useEvent<T extends (...args: never[]) => unknown>( + callback: T +): T { + const ref = useRef<T>(callback); + + useIsomorphicLayoutEffect(() => { + ref.current = callback; + }, [callback]); + + // @ts-expect-error: TS is right that this callback may be a supertype of T, + // but good enough for our use + return useCallback<T>((...args) => ref.current(...args), []); +} + +export function useShallowMemoObject<O extends object>(obj: O): O { + const deps = Object.entries(obj); + deps.sort((a, b) => a[0].localeCompare(b[0])); + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(() => obj, deps.flat()); +} + +export class ReactContextError extends Error { + constructor(providerName: string, additionalInfo?: string) { + super(); + this.name = "ReactContextError"; + this.message = `Hook ${ + this.stack?.split("\n")[1]?.match(/at (?:\w+\.)?(?<name>\w+)/)?.groups! + .name ?? "" + } is called outside the <${providerName}>. ${additionalInfo ?? ""}`; + } +} diff --git a/packages/docusaurus-theme-openapi-docs/src/utils/scrollUtils.tsx b/packages/docusaurus-theme-openapi-docs/src/utils/scrollUtils.tsx new file mode 100644 index 000000000..6490a2ca6 --- /dev/null +++ b/packages/docusaurus-theme-openapi-docs/src/utils/scrollUtils.tsx @@ -0,0 +1,154 @@ +/* ============================================================================ + * Portions Copyright (c) Meta Platforms, Inc. and affiliates. + * Portions Copyright (c) Palo Alto Networks + * + * Vendored subset of @docusaurus/theme-common/src/utils/scrollUtils.tsx (MIT) + * to remove the dependency on @docusaurus/theme-common/internal. Only the + * ScrollControllerProvider + useScrollPositionBlocker surface is kept, since + * that's all our tab renderers need. The ScrollControllerProvider must be + * mounted in the React tree above any consumer of useScrollPositionBlocker + * (see ApiItem/index.tsx). + * See: https://github.com/PaloAltoNetworks/docusaurus-openapi-docs/issues/1140 + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * ========================================================================== */ + +import React, { + useCallback, + useContext, + useMemo, + useRef, + type ReactNode, +} from "react"; + +import useIsomorphicLayoutEffect from "@docusaurus/useIsomorphicLayoutEffect"; + +import { ReactContextError } from "./reactUtils"; + +type ScrollController = { + scrollEventsEnabledRef: React.RefObject<boolean>; + enableScrollEvents: () => void; + disableScrollEvents: () => void; +}; + +function useScrollControllerContextValue(): ScrollController { + const scrollEventsEnabledRef = useRef(true); + + return useMemo( + () => ({ + scrollEventsEnabledRef, + enableScrollEvents: () => { + scrollEventsEnabledRef.current = true; + }, + disableScrollEvents: () => { + scrollEventsEnabledRef.current = false; + }, + }), + [] + ); +} + +const ScrollMonitorContext = React.createContext<ScrollController | undefined>( + undefined +); + +export function ScrollControllerProvider({ + children, +}: { + children: ReactNode; +}): ReactNode { + const value = useScrollControllerContextValue(); + return ( + <ScrollMonitorContext.Provider value={value}> + {children} + </ScrollMonitorContext.Provider> + ); +} + +function useScrollController(): ScrollController { + const context = useContext(ScrollMonitorContext); + if (context == null) { + throw new ReactContextError("ScrollControllerProvider"); + } + return context; +} + +type UseScrollPositionSaver = { + save: (elem: HTMLElement) => void; + restore: () => { restored: boolean }; +}; + +function useScrollPositionSaver(): UseScrollPositionSaver { + const lastElementRef = useRef<{ elem: HTMLElement | null; top: number }>({ + elem: null, + top: 0, + }); + + const save = useCallback((elem: HTMLElement) => { + lastElementRef.current = { + elem, + top: elem.getBoundingClientRect().top, + }; + }, []); + + const restore = useCallback(() => { + const { + current: { elem, top }, + } = lastElementRef; + if (!elem) { + return { restored: false }; + } + const newTop = elem.getBoundingClientRect().top; + const heightDiff = newTop - top; + if (heightDiff) { + window.scrollBy({ left: 0, top: heightDiff }); + } + lastElementRef.current = { elem: null, top: 0 }; + + return { restored: heightDiff !== 0 }; + }, []); + + return useMemo(() => ({ save, restore }), [restore, save]); +} + +export function useScrollPositionBlocker(): { + blockElementScrollPositionUntilNextRender: (el: HTMLElement) => void; +} { + const scrollController = useScrollController(); + const scrollPositionSaver = useScrollPositionSaver(); + + const nextLayoutEffectCallbackRef = useRef<(() => void) | undefined>( + undefined + ); + + const blockElementScrollPositionUntilNextRender = useCallback( + (el: HTMLElement) => { + scrollPositionSaver.save(el); + scrollController.disableScrollEvents(); + nextLayoutEffectCallbackRef.current = () => { + const { restored } = scrollPositionSaver.restore(); + nextLayoutEffectCallbackRef.current = undefined; + + if (restored) { + const handleScrollRestoreEvent = () => { + scrollController.enableScrollEvents(); + window.removeEventListener("scroll", handleScrollRestoreEvent); + }; + window.addEventListener("scroll", handleScrollRestoreEvent); + } else { + scrollController.enableScrollEvents(); + } + }; + }, + [scrollController, scrollPositionSaver] + ); + + useIsomorphicLayoutEffect(() => { + queueMicrotask(() => nextLayoutEffectCallbackRef.current?.()); + }); + + return { + blockElementScrollPositionUntilNextRender, + }; +} diff --git a/packages/docusaurus-theme-openapi-docs/src/utils/tabsUtils.tsx b/packages/docusaurus-theme-openapi-docs/src/utils/tabsUtils.tsx new file mode 100644 index 000000000..0ed562496 --- /dev/null +++ b/packages/docusaurus-theme-openapi-docs/src/utils/tabsUtils.tsx @@ -0,0 +1,329 @@ +/* ============================================================================ + * Portions Copyright (c) Meta Platforms, Inc. and affiliates. + * Portions Copyright (c) Palo Alto Networks + * + * Vendored from @docusaurus/theme-common/src/utils/tabsUtils.tsx (MIT) to + * remove the dependency on @docusaurus/theme-common/internal. The + * useQueryStringValue dependency from theme-common's historyUtils is inlined + * below to avoid pulling another internal module. + * See: https://github.com/PaloAltoNetworks/docusaurus-openapi-docs/issues/1140 + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * ========================================================================== */ + +import React, { + createContext, + isValidElement, + useCallback, + useMemo, + useState, + useSyncExternalStore, + type ReactElement, + type ReactNode, +} from "react"; + +import { useHistory } from "@docusaurus/router"; +import { duplicates, useStorageSlot } from "@docusaurus/theme-common"; +import useIsomorphicLayoutEffect from "@docusaurus/useIsomorphicLayoutEffect"; + +import { ScrollControllerProvider } from "./scrollUtils"; + +export interface TabValue { + readonly value: string; + readonly label?: string; + readonly attributes?: { [key: string]: unknown }; + readonly default?: boolean; +} + +export interface TabsProps { + readonly lazy?: boolean; + readonly block?: boolean; + readonly children: ReactNode; + readonly defaultValue?: string | null; + readonly values?: readonly TabValue[]; + readonly groupId?: string; + readonly className?: string; + readonly queryString?: string | boolean; +} + +// Extended Tabs type used across the OpenAPI theme; preserves the historical +// shape that included an optional `length` field. +export interface TabProps extends TabsProps { + length?: number; +} + +export interface TabItemProps { + readonly children: ReactNode; + readonly value: string; + readonly default?: boolean; + readonly label?: string; + readonly className?: string; + readonly attributes?: { [key: string]: unknown }; +} + +export function sanitizeTabsChildren(children: ReactNode): ReactNode { + return React.Children.toArray(children).filter((child) => child !== "\n"); +} + +function extractChildrenTabValues(children: ReactNode): TabValue[] { + function isTabItemWithValueProp( + comp: ReactElement + ): comp is ReactElement<TabItemProps> { + const { props } = comp; + return !!props && typeof props === "object" && "value" in props; + } + + const elements = React.Children.toArray(children).flatMap((child) => { + if (!child) { + return []; + } + if (isValidElement(child) && isTabItemWithValueProp(child)) { + return [child]; + } + const badChildTypeName = + // @ts-expect-error: guarding against unexpected cases + typeof child.type === "string" ? child.type : child.type.name; + throw new Error( + `Docusaurus error: Bad <Tabs> child <${badChildTypeName}>: all children of the <Tabs> component should be <TabItem>, and every <TabItem> should have a unique "value" prop. +If you do not want to pass on a "value" prop to the direct children of <Tabs>, you can also pass an explicit <Tabs values={...}> prop.` + ); + }); + + return elements.map( + ({ props: { value, label, attributes, default: isDefault } }) => ({ + value, + label, + attributes, + default: isDefault, + }) + ); +} + +function ensureNoDuplicateValue(values: readonly TabValue[]) { + const dup = duplicates(values, (a, b) => a.value === b.value); + if (dup.length > 0) { + throw new Error( + `Docusaurus error: Duplicate values "${dup + .map((a) => `'${a.value}'`) + .join(", ")}" found in <Tabs>. Every value needs to be unique.` + ); + } +} + +function useTabValues( + props: Pick<TabsProps, "values" | "children"> +): readonly TabValue[] { + const { values: valuesProp, children } = props; + return useMemo(() => { + const values = valuesProp ?? extractChildrenTabValues(children); + ensureNoDuplicateValue(values); + return values; + }, [valuesProp, children]); +} + +function isValidValue({ + value, + tabValues, +}: { + value: string | null | undefined; + tabValues: readonly TabValue[]; +}) { + return tabValues.some((a) => a.value === value); +} + +function getInitialStateValue({ + defaultValue, + tabValues, +}: { + defaultValue: TabsProps["defaultValue"]; + tabValues: readonly TabValue[]; +}): string { + if (tabValues.length === 0) { + throw new Error( + "Docusaurus error: the <Tabs> component requires at least one <TabItem> children component" + ); + } + if (defaultValue) { + if (!isValidValue({ value: defaultValue, tabValues })) { + throw new Error( + `Docusaurus error: The <Tabs> has a defaultValue "${defaultValue}" but none of its children has the corresponding value. Available values are: ${tabValues + .map((a) => a.value) + .join( + ", " + )}. If you intend to show no default tab, use defaultValue={null} instead.` + ); + } + return defaultValue; + } + const defaultTabValue = + tabValues.find((tabValue) => tabValue.default) ?? tabValues[0]; + if (!defaultTabValue) { + throw new Error("Unexpected error: 0 tabValues"); + } + return defaultTabValue.value; +} + +function getStorageKey(groupId: string | undefined) { + if (!groupId) { + return null; + } + return `docusaurus.tab.${groupId}`; +} + +function getQueryStringKey({ + queryString = false, + groupId, +}: Pick<TabsProps, "queryString" | "groupId">) { + if (typeof queryString === "string") { + return queryString; + } + if (queryString === false) { + return null; + } + if (queryString === true && !groupId) { + throw new Error( + `Docusaurus error: The <Tabs> component groupId prop is required if queryString=true, because this value is used as the search param name. You can also provide an explicit value such as queryString="my-search-param".` + ); + } + return groupId ?? null; +} + +// Inlined from @docusaurus/theme-common/internal historyUtils.useQueryStringValue +function useQueryStringValue(key: string | null): string | null { + const history = useHistory(); + return useSyncExternalStore( + history.listen, + () => + key === null + ? null + : new URLSearchParams(history.location.search).get(key), + () => null + ); +} + +function useTabQueryString({ + queryString = false, + groupId, +}: Pick<TabsProps, "queryString" | "groupId">) { + const history = useHistory(); + const key = getQueryStringKey({ queryString, groupId }); + const value = useQueryStringValue(key); + + const setValue = useCallback( + (newValue: string) => { + if (!key) { + return; + } + const searchParams = new URLSearchParams(history.location.search); + searchParams.set(key, newValue); + history.replace({ ...history.location, search: searchParams.toString() }); + }, + [key, history] + ); + + return [value, setValue] as const; +} + +function useTabStorage({ groupId }: Pick<TabsProps, "groupId">) { + const key = getStorageKey(groupId); + const [value, storageSlot] = useStorageSlot(key); + + const setValue = useCallback( + (newValue: string) => { + if (!key) { + return; + } + storageSlot.set(newValue); + }, + [key, storageSlot] + ); + + return [value, setValue] as const; +} + +type TabsContextValue = { + selectedValue: string; + selectValue: (value: string) => void; + tabValues: readonly TabValue[]; + lazy: boolean; + block: boolean; +}; + +export function useTabsContextValue(props: TabsProps): TabsContextValue { + const { defaultValue, queryString = false, groupId } = props; + const tabValues = useTabValues(props); + + const [selectedValue, setSelectedValue] = useState(() => + getInitialStateValue({ defaultValue, tabValues }) + ); + + const [queryStringValue, setQueryString] = useTabQueryString({ + queryString, + groupId, + }); + + const [storageValue, setStorageValue] = useTabStorage({ + groupId, + }); + + const valueToSync = (() => { + const value = queryStringValue ?? storageValue; + if (!isValidValue({ value, tabValues })) { + return null; + } + return value; + })(); + + useIsomorphicLayoutEffect(() => { + if (valueToSync) { + setSelectedValue(valueToSync); + } + }, [valueToSync]); + + const selectValue = useCallback( + (newValue: string) => { + if (!isValidValue({ value: newValue, tabValues })) { + throw new Error(`Can't select invalid tab value=${newValue}`); + } + setSelectedValue(newValue); + setQueryString(newValue); + setStorageValue(newValue); + }, + [setQueryString, setStorageValue, tabValues] + ); + + return { + selectedValue, + selectValue, + tabValues, + lazy: props.lazy ?? false, + block: props.block ?? false, + }; +} + +const TabsContext = createContext<TabsContextValue | null>(null); + +export function useTabs(): TabsContextValue { + const contextValue = React.useContext(TabsContext); + if (!contextValue) { + throw new Error("useTabsContext() must be used within a Tabs component"); + } + return contextValue; +} + +export function TabsProvider(props: { + children: ReactNode; + value: TabsContextValue; +}): ReactNode { + // ScrollControllerProvider is mounted here so every tab consumer + // (our six OpenAPI tab variants + the swizzled @theme/Tabs) gets a working + // useScrollPositionBlocker without callers needing a separate provider. + return ( + <ScrollControllerProvider> + <TabsContext.Provider value={props.value}> + {props.children} + </TabsContext.Provider> + </ScrollControllerProvider> + ); +} diff --git a/packages/docusaurus-theme-openapi-docs/src/utils/useCodeWordWrap.ts b/packages/docusaurus-theme-openapi-docs/src/utils/useCodeWordWrap.ts new file mode 100644 index 000000000..820f4d801 --- /dev/null +++ b/packages/docusaurus-theme-openapi-docs/src/utils/useCodeWordWrap.ts @@ -0,0 +1,110 @@ +/* ============================================================================ + * Portions Copyright (c) Meta Platforms, Inc. and affiliates. + * Portions Copyright (c) Palo Alto Networks + * + * Vendored from @docusaurus/theme-common/src/hooks/useCodeWordWrap.ts (MIT) + * to remove the dependency on @docusaurus/theme-common/internal. + * See: https://github.com/PaloAltoNetworks/docusaurus-openapi-docs/issues/1140 + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * ========================================================================== */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { RefObject } from "react"; + +import { useMutationObserver } from "./useMutationObserver"; + +// Callback fires when the "hidden" attribute of a tabpanel changes +// See https://github.com/facebook/docusaurus/pull/7485 +function useTabBecameVisibleCallback( + codeBlockRef: RefObject<HTMLPreElement | null>, + callback: () => void +) { + const [hiddenTabElement, setHiddenTabElement] = useState< + Element | null | undefined + >(); + + const updateHiddenTabElement = useCallback(() => { + setHiddenTabElement( + codeBlockRef.current?.closest("[role=tabpanel][hidden]") + ); + }, [codeBlockRef, setHiddenTabElement]); + + useEffect(() => { + updateHiddenTabElement(); + }, [updateHiddenTabElement]); + + useMutationObserver( + hiddenTabElement, + (mutations: MutationRecord[]) => { + mutations.forEach((mutation) => { + if ( + mutation.type === "attributes" && + mutation.attributeName === "hidden" + ) { + callback(); + updateHiddenTabElement(); + } + }); + }, + { + attributes: true, + characterData: false, + childList: false, + subtree: false, + } + ); +} + +export type WordWrap = { + readonly codeBlockRef: RefObject<HTMLPreElement | null>; + readonly isEnabled: boolean; + readonly isCodeScrollable: boolean; + readonly toggle: () => void; +}; + +export function useCodeWordWrap(): WordWrap { + const [isEnabled, setIsEnabled] = useState(false); + const [isCodeScrollable, setIsCodeScrollable] = useState<boolean>(false); + const codeBlockRef = useRef<HTMLPreElement>(null); + + const toggle = useCallback(() => { + const codeElement = codeBlockRef.current!.querySelector("code")!; + + if (isEnabled) { + codeElement.removeAttribute("style"); + } else { + codeElement.style.whiteSpace = "pre-wrap"; + codeElement.style.overflowWrap = "anywhere"; + } + + setIsEnabled((value) => !value); + }, [codeBlockRef, isEnabled]); + + const updateCodeIsScrollable = useCallback(() => { + const { scrollWidth, clientWidth } = codeBlockRef.current!; + const isScrollable = + scrollWidth > clientWidth || + codeBlockRef.current!.querySelector("code")!.hasAttribute("style"); + setIsCodeScrollable(isScrollable); + }, [codeBlockRef]); + + useTabBecameVisibleCallback(codeBlockRef, updateCodeIsScrollable); + + useEffect(() => { + updateCodeIsScrollable(); + }, [isEnabled, updateCodeIsScrollable]); + + useEffect(() => { + window.addEventListener("resize", updateCodeIsScrollable, { + passive: true, + }); + + return () => { + window.removeEventListener("resize", updateCodeIsScrollable); + }; + }, [updateCodeIsScrollable]); + + return { codeBlockRef, isEnabled, isCodeScrollable, toggle }; +} diff --git a/packages/docusaurus-theme-openapi-docs/src/utils/useMutationObserver.ts b/packages/docusaurus-theme-openapi-docs/src/utils/useMutationObserver.ts new file mode 100644 index 000000000..d81a83c48 --- /dev/null +++ b/packages/docusaurus-theme-openapi-docs/src/utils/useMutationObserver.ts @@ -0,0 +1,41 @@ +/* ============================================================================ + * Portions Copyright (c) Meta Platforms, Inc. and affiliates. + * Portions Copyright (c) Palo Alto Networks + * + * Vendored from @docusaurus/theme-common/src/hooks/useMutationObserver.ts (MIT) + * to remove the dependency on @docusaurus/theme-common/internal. + * See: https://github.com/PaloAltoNetworks/docusaurus-openapi-docs/issues/1140 + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * ========================================================================== */ + +import { useEffect } from "react"; + +import { useEvent, useShallowMemoObject } from "./reactUtils"; + +type Options = MutationObserverInit; + +const DefaultOptions: Options = { + attributes: true, + characterData: true, + childList: true, + subtree: true, +}; + +export function useMutationObserver( + target: Element | undefined | null, + callback: MutationCallback, + options: Options = DefaultOptions +): void { + const stableCallback = useEvent(callback); + const stableOptions: Options = useShallowMemoObject(options); + + useEffect(() => { + const observer = new MutationObserver(stableCallback); + if (target) { + observer.observe(target, stableOptions); + } + return () => observer.disconnect(); + }, [target, stableCallback, stableOptions]); +} From ebdafdd59734dfaaf050ac3a2c3a0367e02f3856 Mon Sep 17 00:00:00 2001 From: Steven Serrata <sserrata@paloaltonetworks.com> Date: Tue, 16 Jun 2026 08:09:51 -0500 Subject: [PATCH 2/2] refactor(theme): move vendored utils under src/theme/utils for @theme alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avoid `../../../utils/...` relative paths by relocating the vendored utilities into `src/theme/utils/` so they can be imported via the existing `@theme/*` alias (`@theme/utils/codeBlockUtils`, `@theme/utils/tabsUtils`, etc.). The `@theme/*` alias is dual-purpose: tsconfig path mapping for type-checking within this package, and a Docusaurus webpack runtime alias resolved by the consuming site. Existing codebase precedent: `@theme/translationIds`, `@theme/ApiItem/store`, `@theme/ApiItem/hooks` — non-component utility files already live under `src/theme/` and are consumed via `@theme/...` at both compile and runtime. No functional changes; pure file move + import rewrite. Cross-references inside the utils directory remain as sibling `./` paths. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --- .../src/theme/ApiExplorer/ApiCodeBlock/Container/index.tsx | 2 +- .../src/theme/ApiExplorer/ApiCodeBlock/Content/String.tsx | 4 ++-- .../src/theme/ApiExplorer/CodeTabs/index.tsx | 4 ++-- .../docusaurus-theme-openapi-docs/src/theme/ApiTabs/index.tsx | 4 ++-- .../src/theme/DiscriminatorTabs/index.tsx | 4 ++-- .../src/theme/MimeTabs/index.tsx | 4 ++-- .../src/theme/OperationTabs/index.tsx | 4 ++-- .../src/theme/SchemaTabs/index.tsx | 4 ++-- .../docusaurus-theme-openapi-docs/src/theme/TabItem/index.tsx | 2 +- .../docusaurus-theme-openapi-docs/src/theme/Tabs/index.tsx | 4 ++-- .../src/{ => theme}/utils/codeBlockUtils.ts | 0 .../src/{ => theme}/utils/reactUtils.ts | 0 .../src/{ => theme}/utils/scrollUtils.tsx | 0 .../src/{ => theme}/utils/tabsUtils.tsx | 0 .../src/{ => theme}/utils/useCodeWordWrap.ts | 0 .../src/{ => theme}/utils/useMutationObserver.ts | 0 16 files changed, 18 insertions(+), 18 deletions(-) rename packages/docusaurus-theme-openapi-docs/src/{ => theme}/utils/codeBlockUtils.ts (100%) rename packages/docusaurus-theme-openapi-docs/src/{ => theme}/utils/reactUtils.ts (100%) rename packages/docusaurus-theme-openapi-docs/src/{ => theme}/utils/scrollUtils.tsx (100%) rename packages/docusaurus-theme-openapi-docs/src/{ => theme}/utils/tabsUtils.tsx (100%) rename packages/docusaurus-theme-openapi-docs/src/{ => theme}/utils/useCodeWordWrap.ts (100%) rename packages/docusaurus-theme-openapi-docs/src/{ => theme}/utils/useMutationObserver.ts (100%) diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/ApiCodeBlock/Container/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/ApiCodeBlock/Container/index.tsx index 5698a420a..acf3f8691 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/ApiCodeBlock/Container/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/ApiCodeBlock/Container/index.tsx @@ -10,7 +10,7 @@ import React, { ComponentProps } from "react"; import { ThemeClassNames, usePrismTheme } from "@docusaurus/theme-common"; import clsx from "clsx"; -import { getPrismCssVariables } from "../../../../utils/codeBlockUtils"; +import { getPrismCssVariables } from "@theme/utils/codeBlockUtils"; export default function CodeBlockContainer<T extends "div" | "pre">({ as: As, diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/ApiCodeBlock/Content/String.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/ApiCodeBlock/Content/String.tsx index e34fb456c..fff575b7c 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/ApiCodeBlock/Content/String.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/ApiCodeBlock/Content/String.tsx @@ -22,8 +22,8 @@ import { parseCodeBlockTitle, parseLanguage, parseLines, -} from "../../../../utils/codeBlockUtils"; -import { useCodeWordWrap } from "../../../../utils/useCodeWordWrap"; +} from "@theme/utils/codeBlockUtils"; +import { useCodeWordWrap } from "@theme/utils/useCodeWordWrap"; export default function CodeBlockString({ children, diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/CodeTabs/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/CodeTabs/index.tsx index 046db9944..1ce08df77 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/CodeTabs/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/CodeTabs/index.tsx @@ -10,14 +10,14 @@ import React, { cloneElement, ReactElement, useEffect, useRef } from "react"; import useIsBrowser from "@docusaurus/useIsBrowser"; import clsx from "clsx"; -import { useScrollPositionBlocker } from "../../../utils/scrollUtils"; +import { useScrollPositionBlocker } from "@theme/utils/scrollUtils"; import { sanitizeTabsChildren, type TabItemProps, type TabProps, TabsProvider, useTabsContextValue, -} from "../../../utils/tabsUtils"; +} from "@theme/utils/tabsUtils"; import { Language } from "../CodeSnippets/code-snippets-types"; export interface Props { diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/ApiTabs/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/ApiTabs/index.tsx index 57f0d9ae4..ff82d82f1 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/ApiTabs/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/ApiTabs/index.tsx @@ -18,14 +18,14 @@ import useIsBrowser from "@docusaurus/useIsBrowser"; import Heading from "@theme/Heading"; import clsx from "clsx"; -import { useScrollPositionBlocker } from "../../utils/scrollUtils"; +import { useScrollPositionBlocker } from "@theme/utils/scrollUtils"; import { sanitizeTabsChildren, type TabItemProps, TabProps, TabsProvider, useTabsContextValue, -} from "../../utils/tabsUtils"; +} from "@theme/utils/tabsUtils"; export interface TabListProps extends TabProps { label: string; diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/DiscriminatorTabs/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/DiscriminatorTabs/index.tsx index 2be7cf1a4..99da7a7f6 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/DiscriminatorTabs/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/DiscriminatorTabs/index.tsx @@ -17,14 +17,14 @@ import useIsBrowser from "@docusaurus/useIsBrowser"; import clsx from "clsx"; import flatten from "lodash/flatten"; -import { useScrollPositionBlocker } from "../../utils/scrollUtils"; +import { useScrollPositionBlocker } from "@theme/utils/scrollUtils"; import { sanitizeTabsChildren, type TabItemProps, TabProps, TabsProvider, useTabsContextValue, -} from "../../utils/tabsUtils"; +} from "@theme/utils/tabsUtils"; function TabList({ className, diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/MimeTabs/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/MimeTabs/index.tsx index ac07c133d..0feb2bb84 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/MimeTabs/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/MimeTabs/index.tsx @@ -20,14 +20,14 @@ import { useTypedDispatch, useTypedSelector } from "@theme/ApiItem/hooks"; import { RootState } from "@theme/ApiItem/store"; import clsx from "clsx"; -import { useScrollPositionBlocker } from "../../utils/scrollUtils"; +import { useScrollPositionBlocker } from "@theme/utils/scrollUtils"; import { sanitizeTabsChildren, type TabItemProps, TabProps, TabsProvider, useTabsContextValue, -} from "../../utils/tabsUtils"; +} from "@theme/utils/tabsUtils"; export interface Props { schemaType: any; diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/OperationTabs/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/OperationTabs/index.tsx index f7eb65b5d..731e4f1c1 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/OperationTabs/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/OperationTabs/index.tsx @@ -16,14 +16,14 @@ import React, { import useIsBrowser from "@docusaurus/useIsBrowser"; import clsx from "clsx"; -import { useScrollPositionBlocker } from "../../utils/scrollUtils"; +import { useScrollPositionBlocker } from "@theme/utils/scrollUtils"; import { sanitizeTabsChildren, type TabItemProps, TabProps, TabsProvider, useTabsContextValue, -} from "../../utils/tabsUtils"; +} from "@theme/utils/tabsUtils"; function TabList({ className, diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/SchemaTabs/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaTabs/index.tsx index 0ca2bc97a..838c785de 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/SchemaTabs/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaTabs/index.tsx @@ -18,14 +18,14 @@ import useIsBrowser from "@docusaurus/useIsBrowser"; import clsx from "clsx"; import flatten from "lodash/flatten"; -import { useScrollPositionBlocker } from "../../utils/scrollUtils"; +import { useScrollPositionBlocker } from "@theme/utils/scrollUtils"; import { sanitizeTabsChildren, type TabItemProps, TabProps, TabsProvider, useTabsContextValue, -} from "../../utils/tabsUtils"; +} from "@theme/utils/tabsUtils"; export interface SchemaTabsProps extends TabProps { /** diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/TabItem/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/TabItem/index.tsx index 93d5c5237..eebb4ca1b 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/TabItem/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/TabItem/index.tsx @@ -16,7 +16,7 @@ import React, { type ReactNode } from "react"; import clsx from "clsx"; -import { type TabItemProps, useTabs } from "../../utils/tabsUtils"; +import { type TabItemProps, useTabs } from "@theme/utils/tabsUtils"; type Props = TabItemProps; import styles from "./styles.module.css"; diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/Tabs/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/Tabs/index.tsx index 7712e0ee9..0f0b44517 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/Tabs/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/Tabs/index.tsx @@ -18,14 +18,14 @@ import { ThemeClassNames } from "@docusaurus/theme-common"; import useIsBrowser from "@docusaurus/useIsBrowser"; import clsx from "clsx"; -import { useScrollPositionBlocker } from "../../utils/scrollUtils"; +import { useScrollPositionBlocker } from "@theme/utils/scrollUtils"; import { sanitizeTabsChildren, type TabsProps, TabsProvider, useTabs, useTabsContextValue, -} from "../../utils/tabsUtils"; +} from "@theme/utils/tabsUtils"; import styles from "./styles.module.css"; type Props = TabsProps; diff --git a/packages/docusaurus-theme-openapi-docs/src/utils/codeBlockUtils.ts b/packages/docusaurus-theme-openapi-docs/src/theme/utils/codeBlockUtils.ts similarity index 100% rename from packages/docusaurus-theme-openapi-docs/src/utils/codeBlockUtils.ts rename to packages/docusaurus-theme-openapi-docs/src/theme/utils/codeBlockUtils.ts diff --git a/packages/docusaurus-theme-openapi-docs/src/utils/reactUtils.ts b/packages/docusaurus-theme-openapi-docs/src/theme/utils/reactUtils.ts similarity index 100% rename from packages/docusaurus-theme-openapi-docs/src/utils/reactUtils.ts rename to packages/docusaurus-theme-openapi-docs/src/theme/utils/reactUtils.ts diff --git a/packages/docusaurus-theme-openapi-docs/src/utils/scrollUtils.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/utils/scrollUtils.tsx similarity index 100% rename from packages/docusaurus-theme-openapi-docs/src/utils/scrollUtils.tsx rename to packages/docusaurus-theme-openapi-docs/src/theme/utils/scrollUtils.tsx diff --git a/packages/docusaurus-theme-openapi-docs/src/utils/tabsUtils.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/utils/tabsUtils.tsx similarity index 100% rename from packages/docusaurus-theme-openapi-docs/src/utils/tabsUtils.tsx rename to packages/docusaurus-theme-openapi-docs/src/theme/utils/tabsUtils.tsx diff --git a/packages/docusaurus-theme-openapi-docs/src/utils/useCodeWordWrap.ts b/packages/docusaurus-theme-openapi-docs/src/theme/utils/useCodeWordWrap.ts similarity index 100% rename from packages/docusaurus-theme-openapi-docs/src/utils/useCodeWordWrap.ts rename to packages/docusaurus-theme-openapi-docs/src/theme/utils/useCodeWordWrap.ts diff --git a/packages/docusaurus-theme-openapi-docs/src/utils/useMutationObserver.ts b/packages/docusaurus-theme-openapi-docs/src/theme/utils/useMutationObserver.ts similarity index 100% rename from packages/docusaurus-theme-openapi-docs/src/utils/useMutationObserver.ts rename to packages/docusaurus-theme-openapi-docs/src/theme/utils/useMutationObserver.ts