diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx index 9ecdd2dd9c..d4554da3ef 100644 --- a/apps/design-system/__registry__/index.tsx +++ b/apps/design-system/__registry__/index.tsx @@ -1864,6 +1864,28 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, + "toc-demo": { + name: "toc-demo", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/toc-demo")), + source: "", + files: ["registry/default/example/toc-demo.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "toc-single-demo": { + name: "toc-single-demo", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/toc-single-demo")), + source: "", + files: ["registry/default/example/toc-single-demo.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, "multi-select-demo": { name: "multi-select-demo", type: "components:example", diff --git a/apps/design-system/config/docs.ts b/apps/design-system/config/docs.ts index e96ec0b2e5..1031738338 100644 --- a/apps/design-system/config/docs.ts +++ b/apps/design-system/config/docs.ts @@ -122,6 +122,11 @@ export const docsConfig: DocsConfig = { href: '/docs/fragments/logs-bar-chart', items: [], }, + { + title: 'Table of Contents (TOC)', + href: '/docs/fragments/toc', + items: [], + }, ], }, { diff --git a/apps/design-system/content/docs/fragments/toc.mdx b/apps/design-system/content/docs/fragments/toc.mdx new file mode 100644 index 0000000000..998a1ad9eb --- /dev/null +++ b/apps/design-system/content/docs/fragments/toc.mdx @@ -0,0 +1,18 @@ +--- +title: Table of Contents (TOC) +description: List of page anchors for the current page. +component: true +fragment: true +--- + +## Usage + +Highlight one or more elements of the TOC if in view. + + + +## Single=true + +Highlight one element of the TOC at a time. + + diff --git a/apps/design-system/registry/default/example/toc-demo.tsx b/apps/design-system/registry/default/example/toc-demo.tsx new file mode 100644 index 0000000000..2de1b7fdfe --- /dev/null +++ b/apps/design-system/registry/default/example/toc-demo.tsx @@ -0,0 +1,227 @@ +'use client' +import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react' +import { Toc, TOCItems, TOCScrollArea } from 'ui-patterns' + +export default function MultiSelectDemo() { + return ( + +
+
+
+

Getting Started with Cloud Computing

+ +

+ Introduction + +

+

+ Cloud computing has revolutionized how we build and deploy applications. This guide + will walk you through the fundamental concepts and best practices. +

+ +

+ Key Concepts + +

+

+ Before diving deep into cloud services, it's important to understand the basic + building blocks that make cloud computing possible. +

+ +

+ Infrastructure as a Service (IaaS) + +

+

+ IaaS provides virtualized computing resources over the internet. This includes virtual + machines, storage, and networking. +

+ +

+ Platform as a Service (PaaS) + +

+

+ PaaS delivers a platform allowing customers to develop, run, and manage applications + without dealing with infrastructure. +

+ +

+ Best Practices + +

+

+ Following cloud computing best practices ensures optimal performance, security, and + cost-effectiveness. +

+ +

+ Security Considerations + +

+

+ Security should be your top priority when working with cloud services. Implement + proper authentication, encryption, and access controls. +

+ +

+ Cost Optimization + +

+

+ Learn how to optimize your cloud spending through resource planning, monitoring, and + implementing cost-saving strategies. +

+ +

+ Conclusion + +

+

+ Cloud computing continues to evolve, offering new possibilities for businesses and + developers alike. Stay updated with the latest trends and best practices. +

+
+
+ +
+
+ ) +} + +const TocComponent = () => { + const { toc } = useTocAnchors() + + return ( + +

+ On this page +

+ + + +
+ ) +} + +import { type AnchorProviderProps, AnchorProvider } from 'ui-patterns' + +interface TOCHeader { + id?: string + text: string + link: string + level: number +} + +const TocAnchorsContext = createContext(undefined) + +const useTocAnchors = () => { + const context = useContext(TocAnchorsContext) + if (!context) { + throw new Error('useTocAnchors must be used within an TocAnchorsContext') + } + return context +} + +const TocAnchorsProvider = ({ children }: PropsWithChildren) => { + const [tocList, setTocList] = useState([]) + + const toc = tocList.map((item) => ({ + title: item.text, + url: item.link, + depth: item.level, + })) + + useEffect(() => { + /** + * Because we're directly querying the DOM, needs the setTimeout so the DOM + * update will happen first. + */ + const timeoutHandle = setTimeout(() => { + const headings = Array.from( + document.querySelector('#example-toc-demo')?.querySelectorAll('h2, h3') ?? [] + ) + + const newHeadings = headings + .filter((heading) => heading.id) + .map((heading) => { + const text = heading?.textContent?.replace('#', '') + const link = heading.querySelector('a')?.getAttribute('href') + if (!link) return null + + const level = heading.tagName === 'H2' ? 2 : 3 + + return { text, link, level } as Partial + }) + .filter((x): x is TOCHeader => !!x && !!x.text && !!x.link && !!x.level) + + setTocList(newHeadings) + }, 100) + + return () => clearTimeout(timeoutHandle) + /** + * window.location.href needed to recalculate toc when page changes, + * `useSubscribeTocRerender` above will trigger the rerender + */ + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [typeof window !== 'undefined' && window.location.href]) + + return ( + + + {children} + + + ) +} diff --git a/apps/design-system/registry/default/example/toc-single-demo.tsx b/apps/design-system/registry/default/example/toc-single-demo.tsx new file mode 100644 index 0000000000..d7fe367d0c --- /dev/null +++ b/apps/design-system/registry/default/example/toc-single-demo.tsx @@ -0,0 +1,227 @@ +'use client' +import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react' +import { Toc, TOCItems, TOCScrollArea } from 'ui-patterns' + +export default function MultiSelectDemo() { + return ( + +
+
+
+

Getting Started with Cloud Computing

+ +

+ Introduction + +

+

+ Cloud computing has revolutionized how we build and deploy applications. This guide + will walk you through the fundamental concepts and best practices. +

+ +

+ Key Concepts + +

+

+ Before diving deep into cloud services, it's important to understand the basic + building blocks that make cloud computing possible. +

+ +

+ Infrastructure as a Service (IaaS) + +

+

+ IaaS provides virtualized computing resources over the internet. This includes virtual + machines, storage, and networking. +

+ +

+ Platform as a Service (PaaS) + +

+

+ PaaS delivers a platform allowing customers to develop, run, and manage applications + without dealing with infrastructure. +

+ +

+ Best Practices + +

+

+ Following cloud computing best practices ensures optimal performance, security, and + cost-effectiveness. +

+ +

+ Security Considerations + +

+

+ Security should be your top priority when working with cloud services. Implement + proper authentication, encryption, and access controls. +

+ +

+ Cost Optimization + +

+

+ Learn how to optimize your cloud spending through resource planning, monitoring, and + implementing cost-saving strategies. +

+ +

+ Conclusion + +

+

+ Cloud computing continues to evolve, offering new possibilities for businesses and + developers alike. Stay updated with the latest trends and best practices. +

+
+
+ +
+
+ ) +} + +const TocComponent = () => { + const { toc } = useTocAnchors() + + return ( + +

+ On this page +

+ + + +
+ ) +} + +import { type AnchorProviderProps, AnchorProvider } from 'ui-patterns' + +interface TOCHeader { + id?: string + text: string + link: string + level: number +} + +const TocAnchorsContext = createContext(undefined) + +const useTocAnchors = () => { + const context = useContext(TocAnchorsContext) + if (!context) { + throw new Error('useTocAnchors must be used within an TocAnchorsContext') + } + return context +} + +const TocAnchorsProvider = ({ children }: PropsWithChildren) => { + const [tocList, setTocList] = useState([]) + + const toc = tocList.map((item) => ({ + title: item.text, + url: item.link, + depth: item.level, + })) + + useEffect(() => { + /** + * Because we're directly querying the DOM, needs the setTimeout so the DOM + * update will happen first. + */ + const timeoutHandle = setTimeout(() => { + const headings = Array.from( + document.querySelector('#example-toc-single-demo')?.querySelectorAll('h2, h3') ?? [] + ) + + const newHeadings = headings + .filter((heading) => heading.id) + .map((heading) => { + const text = heading?.textContent?.replace('#', '') + const link = heading.querySelector('a')?.getAttribute('href') + if (!link) return null + + const level = heading.tagName === 'H2' ? 2 : 3 + + return { text, link, level } as Partial + }) + .filter((x): x is TOCHeader => !!x && !!x.text && !!x.link && !!x.level) + + setTocList(newHeadings) + }, 100) + + return () => clearTimeout(timeoutHandle) + /** + * window.location.href needed to recalculate toc when page changes, + * `useSubscribeTocRerender` above will trigger the rerender + */ + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [typeof window !== 'undefined' && window.location.href]) + + return ( + + + {children} + + + ) +} diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts index a9f4de3e7c..2198f2527a 100644 --- a/apps/design-system/registry/examples.ts +++ b/apps/design-system/registry/examples.ts @@ -1066,6 +1066,16 @@ export const examples: Registry = [ type: 'components:example', files: ['example/inner-side-menu-with-search.tsx'], }, + { + name: 'toc-demo', + type: 'components:example', + files: ['example/toc-demo.tsx'], + }, + { + name: 'toc-single-demo', + type: 'components:example', + files: ['example/toc-single-demo.tsx'], + }, { name: 'multi-select-demo', type: 'components:example', diff --git a/apps/docs/components/GuidesTableOfContents.tsx b/apps/docs/components/GuidesTableOfContents.tsx index 6895fee3ba..46af6ccbe1 100644 --- a/apps/docs/components/GuidesTableOfContents.tsx +++ b/apps/docs/components/GuidesTableOfContents.tsx @@ -1,67 +1,11 @@ 'use client' import { usePathname } from 'next/navigation' -import { Fragment, useEffect, useState } from 'react' import { cn } from 'ui' import { ExpandableVideo } from 'ui-patterns/ExpandableVideo' -import { proxy, useSnapshot } from 'valtio' -import { - highlightSelectedTocItem, - removeAnchor, -} from 'ui/src/components/CustomHTMLElements/CustomHTMLElements.utils' import { Feedback } from '~/components/Feedback' -import useHash from '~/hooks/useHash' - -const formatSlug = (slug: string) => { - // [Joshen] We will still provide support for headers declared like this: - // ## REST API {#rest-api-overview} - // At least for now, this was a docusaurus thing. - if (slug.includes('#')) return slug.split('#')[1] - return slug -} - -function formatTOCHeader(content: string) { - let insideInlineCode = false - const res: Array<{ type: 'text'; value: string } | { type: 'code'; value: string }> = [] - - for (const x of content) { - if (x === '`') { - if (!insideInlineCode) { - insideInlineCode = true - res.push({ type: 'code', value: '' }) - } else { - insideInlineCode = false - } - } else { - if (insideInlineCode) { - res[res.length - 1].value += x - } else { - if (res.length === 0 || res[res.length - 1].type === 'code') { - res.push({ type: 'text', value: x }) - } else { - res[res.length - 1].value += x - } - } - } - } - - return res -} - -const tocRenderSwitch = proxy({ - renderFlag: 0, - toggleRenderFlag: () => void (tocRenderSwitch.renderFlag = (tocRenderSwitch.renderFlag + 1) % 2), -}) - -const useSubscribeTocRerender = () => { - const { renderFlag } = useSnapshot(tocRenderSwitch) - return void renderFlag // Prevent it from being detected as unused code -} - -const useTocRerenderTrigger = () => { - const { toggleRenderFlag } = useSnapshot(tocRenderSwitch) - return toggleRenderFlag -} +import { Toc, TOCItems, TOCScrollArea } from 'ui-patterns' +import { useTocAnchors } from '../features/docs/GuidesMdx.client' interface TOCHeader { id?: string @@ -70,114 +14,37 @@ interface TOCHeader { level: number } -const GuidesTableOfContents = ({ - className, - overrideToc, - video, -}: { - className?: string - overrideToc?: Array - video?: string -}) => { - useSubscribeTocRerender() - const [tocList, setTocList] = useState([]) +const GuidesTableOfContents = ({ className, video }: { className?: string; video?: string }) => { const pathname = usePathname() - const [hash] = useHash() - - const displayedList = overrideToc ?? tocList - - useEffect(() => { - if (overrideToc) return - - /** - * Because we're directly querying the DOM, needs the setTimeout so the DOM - * update will happen first. - */ - const timeoutHandle = setTimeout(() => { - const headings = Array.from( - document.querySelector('#sb-docs-guide-main-article')?.querySelectorAll('h2, h3') ?? [] - ) - - const newHeadings = headings - .filter((heading) => heading.id) - .map((heading) => { - const text = heading.textContent.replace('#', '') - const link = heading.querySelector('a')?.getAttribute('href') - if (!link) return null - - const level = heading.tagName === 'H2' ? 2 : 3 - - return { text, link, level } as Partial - }) - .filter((x): x is TOCHeader => !!x && !!x.text && !!x.link && !!x.level) - setTocList(newHeadings) - }) - - return () => clearTimeout(timeoutHandle) - /** - * window.location.href needed to recalculate toc when page changes, - * `useSubscribeTocRerender` above will trigger the rerender - */ - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [overrideToc, typeof window !== 'undefined' && window.location.href]) - - useEffect(() => { - if (hash && displayedList.length > 0) { - highlightSelectedTocItem(hash) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hash, JSON.stringify(displayedList)]) + const { toc } = useTocAnchors() const tocVideoPreview = `https://img.youtube.com/vi/${video}/0.jpg` return ( -
- {video && ( -
- +
+
+ {video && ( +
+ +
+ )} +
+
- )} -
- + {toc.length !== 0 && ( + +

+ On this page +

+ + + +
+ )}
- {displayedList.length > 0 && ( - - )}
) } export default GuidesTableOfContents -export { useTocRerenderTrigger } export type { TOCHeader } diff --git a/apps/docs/features/docs/GuidesMdx.client.tsx b/apps/docs/features/docs/GuidesMdx.client.tsx index b0bc609650..1a8b4c5b41 100644 --- a/apps/docs/features/docs/GuidesMdx.client.tsx +++ b/apps/docs/features/docs/GuidesMdx.client.tsx @@ -1,16 +1,104 @@ 'use client' +import { proxy, useSnapshot } from 'valtio' + /** * The MDXProvider is necessary so that MDX partials will have access * to components. */ import { MDXProvider } from '@mdx-js/react' -import { type PropsWithChildren } from 'react' +import { createContext, useContext, useEffect, useState, type PropsWithChildren } from 'react' import { components } from '~/features/docs/MdxBase.shared' +import { type AnchorProviderProps, AnchorProvider } from 'ui-patterns' -const MDXProviderGuides = ({ children }: PropsWithChildren) => ( - {children} -) +interface TOCHeader { + id?: string + text: string + link: string + level: number +} -export { MDXProviderGuides } +const TocAnchorsContext = createContext(undefined) + +const useTocAnchors = () => { + const context = useContext(TocAnchorsContext) + if (!context) { + throw new Error('useTocAnchors must be used within an TocAnchorsContext') + } + return context +} + +const useTocRerenderTrigger = () => { + const { toggleRenderFlag } = useSnapshot(tocRenderSwitch) + return toggleRenderFlag +} + +const tocRenderSwitch = proxy({ + renderFlag: 0, + toggleRenderFlag: () => void (tocRenderSwitch.renderFlag = (tocRenderSwitch.renderFlag + 1) % 2), +}) + +const useSubscribeTocRerender = () => { + const { renderFlag } = useSnapshot(tocRenderSwitch) + return void renderFlag // Prevent it from being detected as unused code +} + +const TocAnchorsProvider = ({ children }: PropsWithChildren) => { + const [tocList, setTocList] = useState([]) + useSubscribeTocRerender() + + const displayedList = tocList + const toc = displayedList.map((item) => ({ + title: item.text, + url: item.link, + depth: item.level, + })) + + useEffect(() => { + /** + * Because we're directly querying the DOM, needs the setTimeout so the DOM + * update will happen first. + */ + const timeoutHandle = setTimeout(() => { + const headings = Array.from( + document.querySelector('#sb-docs-guide-main-article')?.querySelectorAll('h2, h3') ?? [] + ) + + const newHeadings = headings + .filter((heading) => heading.id) + .map((heading) => { + const text = heading.textContent.replace('#', '') + const link = heading.querySelector('a')?.getAttribute('href') + if (!link) return null + + const level = heading.tagName === 'H2' ? 2 : 3 + + return { text, link, level } as Partial + }) + .filter((x): x is TOCHeader => !!x && !!x.text && !!x.link && !!x.level) + setTocList(newHeadings) + }) + + return () => clearTimeout(timeoutHandle) + /** + * window.location.href needed to recalculate toc when page changes, + * `useSubscribeTocRerender` above will trigger the rerender + */ + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [typeof window !== 'undefined' && window.location.href]) + + return ( + + + {children} + + + ) +} + +const MDXProviderGuides = ({ children }: PropsWithChildren) => { + return {children} +} + +export { MDXProviderGuides, TocAnchorsProvider, useTocAnchors, useTocRerenderTrigger } diff --git a/apps/docs/features/docs/GuidesMdx.template.tsx b/apps/docs/features/docs/GuidesMdx.template.tsx index 471f94c358..7b4fcab6d7 100644 --- a/apps/docs/features/docs/GuidesMdx.template.tsx +++ b/apps/docs/features/docs/GuidesMdx.template.tsx @@ -7,7 +7,7 @@ import { cn } from 'ui' import Breadcrumbs from '~/components/Breadcrumbs' import GuidesTableOfContents from '~/components/GuidesTableOfContents' -import { MDXProviderGuides } from '~/features/docs/GuidesMdx.client' +import { MDXProviderGuides, TocAnchorsProvider } from '~/features/docs/GuidesMdx.client' import { MDXRemoteBase } from '~/features/docs/MdxBase' import type { WithRequired } from '~/features/helpers.types' import { type GuideFrontmatter } from '~/lib/docs' @@ -64,72 +64,77 @@ const GuideTemplate = ({ meta, content, children, editLink, mdxOptions }: GuideT const hideToc = meta?.hideToc || meta?.hide_table_of_contents return ( -
-
- - -
- {!hideToc && ( - + {meta?.title || 'Supabase Docs'} + + {meta?.subtitle && ( +

+ {meta.subtitle} +

+ )} +
+ + {content && } + {children} + + +
+ {!hideToc && ( + )} - /> - )} -
+
+ + ) } diff --git a/apps/docs/features/ui/Tabs.tsx b/apps/docs/features/ui/Tabs.tsx index 1e52cef36a..cec1363dfb 100644 --- a/apps/docs/features/ui/Tabs.tsx +++ b/apps/docs/features/ui/Tabs.tsx @@ -3,7 +3,7 @@ import { useCallback, type ComponentPropsWithoutRef, type PropsWithChildren } from 'react' import { Tabs as TabsPrimitive, type TabsProps } from 'ui' import { withQueryParams, withSticky, type QueryParamsProps } from 'ui-patterns/ComplexTabs' -import { useTocRerenderTrigger } from '~/components/GuidesTableOfContents' +import { useTocRerenderTrigger } from '~/features/docs/GuidesMdx.client' const TabsWithStickyAndQueryParams = withSticky>( withQueryParams(TabsPrimitive) diff --git a/apps/docs/styles/main.scss b/apps/docs/styles/main.scss index f8ba143bfb..8c25a93b68 100644 --- a/apps/docs/styles/main.scss +++ b/apps/docs/styles/main.scss @@ -166,9 +166,9 @@ pre[class*='language-'] { } // ToC styles -.toc__menu-item--active { - color: hsl(var(--brand-default)) !important; -} +// .toc__menu-item--active { +// color: hsl(var(--brand-default)) !important; +// } .video-container { position: relative; diff --git a/package.json b/package.json index 932b5fffa4..16c61123bc 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,13 @@ "pnpm": ">=9", "node": ">=20" }, - "keywords": ["postgres", "firebase", "storage", "functions", "database", "auth"], + "keywords": [ + "postgres", + "firebase", + "storage", + "functions", + "database", + "auth" + ], "packageManager": "pnpm@9.15.5" } diff --git a/packages/common/helpers.ts b/packages/common/helpers.ts index 4511d81f71..749eb556a4 100644 --- a/packages/common/helpers.ts +++ b/packages/common/helpers.ts @@ -1,4 +1,5 @@ import { useSyncExternalStore } from 'react' +import type * as React from 'react' export const detectBrowser = () => { if (!navigator) return undefined @@ -37,3 +38,17 @@ export const useReducedMotion = (): boolean => { export function ensurePlatformSuffix(apiUrl: string) { return apiUrl.endsWith('/platform') ? apiUrl : `${apiUrl}/platform` } + +export function mergeRefs(...refs: React.Ref[]): React.RefCallback { + return (value) => { + refs.forEach((ref) => { + if (typeof ref === 'function') { + ref(value) + } else if (ref !== null) { + if (typeof ref === 'object' && ref !== null && 'current' in ref) { + ;(ref as any).current = value + } + } + }) + } +} diff --git a/packages/common/hooks/index.ts b/packages/common/hooks/index.ts index 5f30407c91..0e3a707f73 100644 --- a/packages/common/hooks/index.ts +++ b/packages/common/hooks/index.ts @@ -1,10 +1,13 @@ +export * from './useAnchorObserver' export * from './useBreakpoint' export * from './useConstant' export * from './useCopy' export * from './useDebounce' export * from './useDocsSearch' export * from './useDragToClose' +export * from './useEffectEvent' export * from './useIsomorphicLayoutEffect' +export * from './useOnChange' export * from './useParams' export * from './useSearchParamsShallow' export * from './useTelemetryCookie' diff --git a/packages/common/hooks/useAnchorObserver.ts b/packages/common/hooks/useAnchorObserver.ts new file mode 100644 index 0000000000..a482a544a3 --- /dev/null +++ b/packages/common/hooks/useAnchorObserver.ts @@ -0,0 +1,64 @@ +import { useEffect, useState } from 'react' + +/** + * Find the active heading of page + * + * It selects the top heading by default, and the last item when reached the bottom of page. + * + * @param watch - An array of element ids to watch + * @param single - only one active item at most + * @returns Active anchor + */ +export function useAnchorObserver(watch: string[], single: boolean): string[] { + const [activeAnchor, setActiveAnchor] = useState([]) + + useEffect(() => { + let visible: string[] = [] + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting && !visible.includes(entry.target.id)) { + visible = [...visible, entry.target.id] + } else if (!entry.isIntersecting && visible.includes(entry.target.id)) { + visible = visible.filter((v) => v !== entry.target.id) + } + } + + if (visible.length > 0) setActiveAnchor(visible) + }, + { + rootMargin: single ? '-80px 0% -70% 0%' : `-20px 0% -40% 0%`, + threshold: 1, + } + ) + + function onScroll(): void { + const element = document.scrollingElement + if (!element) return + + if (element.scrollTop === 0 && single) setActiveAnchor(watch.slice(0, 1)) + else if (element.scrollTop + element.clientHeight >= element.scrollHeight - 6) { + setActiveAnchor((active) => { + return active.length > 0 && !single + ? watch.slice(watch.indexOf(active[0])) + : watch.slice(-1) + }) + } + } + + for (const heading of watch) { + const element = document.getElementById(heading) + + if (element) observer.observe(element) + } + + onScroll() + window.addEventListener('scroll', onScroll) + return () => { + window.removeEventListener('scroll', onScroll) + observer.disconnect() + } + }, [single, watch]) + + return single ? activeAnchor.slice(0, 1) : activeAnchor +} diff --git a/packages/common/hooks/useEffectEvent.ts b/packages/common/hooks/useEffectEvent.ts new file mode 100644 index 0000000000..9f7dab3625 --- /dev/null +++ b/packages/common/hooks/useEffectEvent.ts @@ -0,0 +1,14 @@ +'use client' +import { useCallback, useRef } from 'react' + +/** + * Don't use this, could be deleted anytime. + * + * @internal + */ +export function useEffectEvent unknown>(callback: F): F { + const ref = useRef(callback) + ref.current = callback + + return useCallback(((...params) => ref.current(...params)) as F, []) +} diff --git a/packages/common/hooks/useOnChange.ts b/packages/common/hooks/useOnChange.ts new file mode 100644 index 0000000000..ffe099a08b --- /dev/null +++ b/packages/common/hooks/useOnChange.ts @@ -0,0 +1,27 @@ +import { useState } from 'react' + +function isDifferent(a: unknown, b: unknown): boolean { + if (Array.isArray(a) && Array.isArray(b)) { + return b.length !== a.length || a.some((v, i) => isDifferent(v, b[i])) + } + + return a !== b +} + +/** + * @param value - state to watch + * @param onChange - when the state changed + * @param isUpdated - a function that determines if the state is updated + */ +export function useOnChange( + value: T, + onChange: (current: T, previous: T) => void, + isUpdated: (prev: T, current: T) => boolean = isDifferent +): void { + const [prev, setPrev] = useState(value) + + if (isUpdated(prev, value)) { + onChange(value, prev) + setPrev(value) + } +} diff --git a/packages/ui-patterns/Toc/index.ts b/packages/ui-patterns/Toc/index.ts new file mode 100644 index 0000000000..1724431f28 --- /dev/null +++ b/packages/ui-patterns/Toc/index.ts @@ -0,0 +1,17 @@ +import * as TocPrimitive from './toc-primitive' +import { ScrollProvider, TOCItem, type AnchorProviderProps, AnchorProvider } from './toc-primitive' +import { Toc, TOCScrollArea, TOCItems } from './toc' +import { TocThumb } from './toc-thumb' +import { type TOCThumb } from './toc-thumb' + +export { + AnchorProvider, + Toc, + TocPrimitive, + TocThumb, + TOCItem, + TOCScrollArea, + TOCItems, + ScrollProvider, +} +export type { AnchorProviderProps, TOCThumb } diff --git a/packages/ui-patterns/Toc/mdx-plugins/remark-heading.ts b/packages/ui-patterns/Toc/mdx-plugins/remark-heading.ts new file mode 100644 index 0000000000..9897e4b310 --- /dev/null +++ b/packages/ui-patterns/Toc/mdx-plugins/remark-heading.ts @@ -0,0 +1,89 @@ +import Slugger from 'github-slugger' +import type { Heading, Root } from 'mdast' +import type { Data, Transformer } from 'unified' +import { visit } from 'unist-util-visit' +import type { TOCItemType } from '../server/get-toc' +import { flattenNode } from './remark-utils' +import type { VFile } from 'vfile' + +const slugger = new Slugger() + +declare module 'mdast' { + export interface HeadingData extends Data { + hProperties?: { + id?: string + } + } +} + +const regex = /\s*\[#([^]+?)]\s*$/ + +export interface RemarkHeadingOptions { + slug?: (root: Root, heading: Heading, text: string) => string + + /** + * Allow custom headings ids + * + * @defaultValue true + */ + customId?: boolean + + /** + * Attach an array of `TOCItemType` to `file.data.toc` + * + * @defaultValue true + */ + generateToc?: boolean +} + +/** + * Add heading ids and extract TOC + */ +export function remarkHeading({ + slug: defaultSlug, + customId = true, + generateToc = true, +}: RemarkHeadingOptions = {}): Transformer { + return (root: Root, file: VFile) => { + const toc: TOCItemType[] = [] + slugger.reset() + + visit(root, 'heading', (heading: any) => { + heading.data ||= {} + heading.data.hProperties ||= {} + + let id = heading.data.hProperties.id + const lastNode = heading.children.at(-1) + + if (!id && lastNode?.type === 'text' && customId) { + const match = regex.exec(lastNode.value) + + if (match?.[1]) { + id = match[1] + lastNode.value = lastNode.value.slice(0, match.index) + } + } + + let flattened: string | undefined + if (!id) { + flattened ??= flattenNode(heading) + + id = defaultSlug ? defaultSlug(root, heading, flattened) : slugger.slug(flattened) + } + + heading.data.hProperties.id = id + + if (generateToc) { + toc.push({ + title: flattened ?? flattenNode(heading), + url: `#${id}`, + depth: heading.depth, + }) + } + + return 'skip' + }) + + if (generateToc) file.data.toc = toc + } +} diff --git a/packages/ui-patterns/Toc/mdx-plugins/remark-utils.ts b/packages/ui-patterns/Toc/mdx-plugins/remark-utils.ts new file mode 100644 index 0000000000..6aa4e48184 --- /dev/null +++ b/packages/ui-patterns/Toc/mdx-plugins/remark-utils.ts @@ -0,0 +1,9 @@ +// import type { RootContent } from 'mdast' + +export function flattenNode(node: any): string { + if ('children' in node) return node.children.map((child: any) => flattenNode(child)).join('') + + if ('value' in node) return node.value + + return '' +} diff --git a/packages/ui-patterns/Toc/server/get-toc.tsx b/packages/ui-patterns/Toc/server/get-toc.tsx new file mode 100644 index 0000000000..30a0e578d4 --- /dev/null +++ b/packages/ui-patterns/Toc/server/get-toc.tsx @@ -0,0 +1,55 @@ +import { remark } from 'remark' +import { remarkHeading } from '../mdx-plugins/remark-heading' +import type { ReactNode } from 'react' +import type { PluggableList } from 'unified' +import type { Compatible } from 'vfile' + +export interface TOCItemType { + title: ReactNode + url: string + depth: number +} + +export type TableOfContents = TOCItemType[] + +/** + * Get Table of Contents from markdown/mdx document (using remark) + * + * @param content - Markdown content or file + */ +export function getTableOfContents(content: Compatible): TableOfContents + +/** + * Get Table of Contents from markdown/mdx document (using remark) + * + * @param content - Markdown content or file + * @param remarkPlugins - remark plugins to be applied first + */ +export function getTableOfContents( + content: Compatible, + remarkPlugins: PluggableList +): Promise + +export function getTableOfContents( + content: Compatible, + remarkPlugins?: PluggableList +): TableOfContents | Promise { + if (remarkPlugins) { + return remark() + .use(remarkPlugins as any) + .use(remarkHeading) + .process(content) + .then((result) => { + if ('toc' in result.data) return result.data.toc as TableOfContents + + return [] + }) + } + + // compatible with previous versions + const result = remark().use(remarkHeading).processSync(content) + + if ('toc' in result.data) return result.data.toc as TableOfContents + + return [] +} diff --git a/packages/ui-patterns/Toc/toc-primitive.tsx b/packages/ui-patterns/Toc/toc-primitive.tsx new file mode 100644 index 0000000000..a3bb5b04c2 --- /dev/null +++ b/packages/ui-patterns/Toc/toc-primitive.tsx @@ -0,0 +1,115 @@ +'use client' + +import { createContext, forwardRef, useContext, useMemo, useRef } from 'react' +import scrollIntoView from 'scroll-into-view-if-needed' +import type { AnchorHTMLAttributes, ReactNode, RefObject } from 'react' +import type { TableOfContents } from './server/get-toc' + +import { mergeRefs, useAnchorObserver, useOnChange } from 'common' + +export const ActiveAnchorContext = createContext([]) + +const ScrollContext = createContext>({ + current: null, +}) + +/** + * The estimated active heading ID + */ +export function useActiveAnchor(): string | undefined { + return useContext(ActiveAnchorContext).at(-1) +} + +/** + * The id of visible anchors + */ +export function useActiveAnchors(): string[] { + return useContext(ActiveAnchorContext) +} + +export interface AnchorProviderProps { + toc: TableOfContents + /** + * Only accept one active item at most + * + * @defaultValue true + */ + single?: boolean + children?: ReactNode +} + +export interface ScrollProviderProps { + /** + * Scroll into the view of container when active + */ + containerRef: RefObject + + children?: ReactNode +} + +export function ScrollProvider({ + containerRef, + children, +}: ScrollProviderProps): React.ReactElement { + return {children} +} + +export function AnchorProvider({ + toc, + single = true, + children, +}: AnchorProviderProps): React.ReactElement { + const headings = useMemo(() => { + return toc.map((item) => item.url.split('#')[1]) + }, [toc]) + + return ( + + {children} + + ) +} + +export interface TOCItemProps extends Omit, 'href'> { + href: string + + onActiveChange?: (v: boolean) => void +} + +export const TOCItem = forwardRef( + ({ onActiveChange, ...props }, ref) => { + const containerRef = useContext(ScrollContext) + + const anchors = useActiveAnchors() + const anchorRef = useRef(null) + const mergedRef = mergeRefs(anchorRef, ref) + + const isActive = anchors.includes(props.href.slice(1)) + + useOnChange(isActive, (v) => { + const element = anchorRef.current + + if (!element) return + + if (v && containerRef.current) { + scrollIntoView(element, { + behavior: 'smooth', + block: 'center', + inline: 'center', + scrollMode: 'always', + boundary: containerRef.current, + }) + } + + onActiveChange?.(v) + }) + + return ( + + {props.children} + + ) + } +) + +TOCItem.displayName = 'TOCItem' diff --git a/packages/ui-patterns/Toc/toc-thumb.tsx b/packages/ui-patterns/Toc/toc-thumb.tsx new file mode 100644 index 0000000000..ed79e71ad5 --- /dev/null +++ b/packages/ui-patterns/Toc/toc-thumb.tsx @@ -0,0 +1,73 @@ +'use client' + +import { type HTMLAttributes, type RefObject, useEffect, useRef } from 'react' +import * as Primitive from './toc-primitive' +import { useEffectEvent, useOnChange } from 'common' + +export type TOCThumb = [top: number, height: number] + +function calc(container: HTMLElement, active: string[]): TOCThumb { + if (active.length === 0 || container.clientHeight === 0) { + return [0, 0] + } + + let upper = Number.MAX_VALUE, + lower = 0 + + for (const item of active) { + const element = container.querySelector(`a[href="#${item}"]`) + + if (!element) continue + + const styles = getComputedStyle(element) + upper = Math.min(upper, element.offsetTop + parseFloat(styles.paddingTop)) + lower = Math.max( + lower, + element.offsetTop + element.clientHeight - parseFloat(styles.paddingBottom) + ) + } + + return [upper, lower - upper] +} + +function update(element: HTMLElement, info: TOCThumb): void { + element.style.setProperty('--toc-top', `${info[0]}px`) + element.style.setProperty('--toc-height', `${info[1]}px`) +} + +export function TocThumb({ + containerRef, + ...props +}: HTMLAttributes & { + containerRef: RefObject +}) { + const active = Primitive.useActiveAnchors() + const thumbRef = useRef(null) + + const onResize = useEffectEvent(() => { + if (!containerRef.current || !thumbRef.current) return + + update(thumbRef.current, calc(containerRef.current, active)) + }) + + useEffect(() => { + if (!containerRef.current) return + const container = containerRef.current + + onResize() + const observer = new ResizeObserver(onResize) + observer.observe(container) + + return () => { + observer.disconnect() + } + }, [containerRef, onResize]) + + useOnChange(active, () => { + if (!containerRef.current || !thumbRef.current) return + + update(thumbRef.current, calc(containerRef.current, active)) + }) + + return
+} diff --git a/packages/ui-patterns/Toc/toc.tsx b/packages/ui-patterns/Toc/toc.tsx new file mode 100644 index 0000000000..0b2e8159cf --- /dev/null +++ b/packages/ui-patterns/Toc/toc.tsx @@ -0,0 +1,151 @@ +'use client' + +import type { TOCItemType } from './server/get-toc' +import * as Primitive from './toc-primitive' +import { type ComponentProps, Fragment, type HTMLAttributes, type ReactNode, useRef } from 'react' +import { TocThumb } from './toc-thumb' +import { cn, ScrollArea, ScrollViewport } from 'ui' +import { removeAnchor } from 'ui/src/components/CustomHTMLElements/CustomHTMLElements.utils' + +export interface TOCProps { + /** + * Custom content in TOC container, before the main TOC + */ + header?: ReactNode + /** + * Custom content in TOC container, after the main TOC + */ + footer?: ReactNode + children: ReactNode +} + +export function Toc(props: HTMLAttributes) { + return ( +
+
+ {props.children} +
+
+ ) +} + +export function TOCScrollArea({ + isMenu, + ...props +}: ComponentProps & { isMenu?: boolean }) { + const viewRef = useRef(null) + + return ( + + + + {props.children} + + + + ) +} + +export function TOCItems({ + items, + showTrack = false, +}: { + items: TOCItemType[] + showTrack?: boolean +}) { + const containerRef = useRef(null) + + if (items.length === 0) return null + + return ( + <> + +
+ {items.map((item) => ( + + ))} +
+ + ) +} + +const formatSlug = (slug: string) => { + // [Joshen] We will still provide support for headers declared like this: + // ## REST API {#rest-api-overview} + // At least for now, this was a docusaurus thing. + if (slug.includes('#')) return slug.split('#')[1] + return slug +} + +function formatTOCHeader(content: string) { + let insideInlineCode = false + const res: Array<{ type: 'text'; value: string } | { type: 'code'; value: string }> = [] + + for (const x of content) { + if (x === '`') { + if (!insideInlineCode) { + insideInlineCode = true + res.push({ type: 'code', value: '' }) + } else { + insideInlineCode = false + } + } else { + if (insideInlineCode) { + res[res.length - 1].value += x + } else { + if (res.length === 0 || res[res.length - 1].type === 'code') { + res.push({ type: 'text', value: x }) + } else { + res[res.length - 1].value += x + } + } + } + } + + return res +} + +function TOCItem({ item }: { item: TOCItemType }) { + return ( + = 4 && 'ps-8' + )} + > + {formatTOCHeader(removeAnchor(item.title)).map((x, index) => ( + + {x.type === 'code' ? ( + {x.value} + ) : ( + x.value + )} + + ))} + + ) +} diff --git a/packages/ui-patterns/index.tsx b/packages/ui-patterns/index.tsx index e244cb346f..8bc8556609 100644 --- a/packages/ui-patterns/index.tsx +++ b/packages/ui-patterns/index.tsx @@ -22,4 +22,5 @@ export * from './PromoToast' export * from './admonition' export * from './ComputeBadge' export * from './TimestampInfo' +export * from './Toc' export * from './LogsBarChart' diff --git a/packages/ui-patterns/package.json b/packages/ui-patterns/package.json index 0536bfa885..163272c1d8 100644 --- a/packages/ui-patterns/package.json +++ b/packages/ui-patterns/package.json @@ -21,8 +21,10 @@ "common-tags": "^1.8.2", "dayjs": "^1.11.13", "framer-motion": "^11.1.9", + "github-slugger": "^2.0.0", "lodash": "*", "lucide-react": "*", + "mdast": "^3.0.0", "monaco-editor": "*", "next-themes": "*", "openai": "^4.20.1", @@ -35,12 +37,15 @@ "react-use": "^17.5.0", "reactflow": "*", "recharts": "^2.8.0", + "remark": "^15.0.1", "remark-gfm": "^4.0.0", + "scroll-into-view-if-needed": "^3.1.0", "sonner": "^1.5.0", "sql-formatter": "^15.0.0", "sse.js": "^2.2.0", "tsconfig": "workspace:*", "ui": "workspace:*", + "unist-util-visit": "^5.0.0", "valtio": "*", "zod": "^3.22.4" }, @@ -51,12 +56,15 @@ "@testing-library/user-event": "^13.5.0", "@types/common-tags": "^1.8.4", "@types/lodash": "^4.17.5", + "@types/mdast": "^3.0.0", "@types/node": "catalog:", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "api-types": "workspace:*", "next-router-mock": "^0.9.13", "typescript": "~5.5.0", + "unified": "^11.0.5", + "vfile": "^6.0.3", "vitest": "^3.0.0" }, "peerDependencies": { diff --git a/packages/ui/index.tsx b/packages/ui/index.tsx index ef837cb4dc..ed2af06337 100644 --- a/packages/ui/index.tsx +++ b/packages/ui/index.tsx @@ -240,7 +240,7 @@ export * from './src/components/shadcn/ui/calendar' export { Toggle as Toggle_Shadcn } from './src/components/shadcn/ui/toggle' -export { ScrollArea, ScrollBar } from './src/components/shadcn/ui/scroll-area' +export { ScrollArea, ScrollBar, ScrollViewport } from './src/components/shadcn/ui/scroll-area' export { Separator } from './src/components/shadcn/ui/separator' diff --git a/packages/ui/src/components/shadcn/ui/scroll-area.tsx b/packages/ui/src/components/shadcn/ui/scroll-area.tsx index 1c813da8c4..1953bc133f 100644 --- a/packages/ui/src/components/shadcn/ui/scroll-area.tsx +++ b/packages/ui/src/components/shadcn/ui/scroll-area.tsx @@ -23,6 +23,21 @@ const ScrollArea = React.forwardRef< )) ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName +const ScrollViewport = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + +)) + +ScrollViewport.displayName = ScrollAreaPrimitive.Viewport.displayName + const ScrollBar = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -43,4 +58,4 @@ const ScrollBar = React.forwardRef< )) ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName -export { ScrollArea, ScrollBar } +export { ScrollArea, ScrollBar, ScrollViewport } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbd9bf7160..6e1ed8c942 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -356,7 +356,7 @@ importers: version: 0.3.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) nuqs: specifier: ^1.19.1 - version: 1.19.1(next@14.2.21(@babel/core@7.24.7(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.72.0)) + version: 1.19.1(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.72.0)) openai: specifier: ^4.20.1 version: 4.71.1(encoding@0.1.13)(zod@3.23.8) @@ -865,7 +865,7 @@ importers: version: 9.3.4 '@testing-library/jest-dom': specifier: ^6.4.6 - version: 6.4.6(@jest/globals@29.7.0(supports-color@8.1.1))(@types/jest@29.5.5)(jest@29.7.0(@types/node@20.12.11)(supports-color@8.1.1))(vitest@3.0.4) + version: 6.4.6(@jest/globals@29.7.0(supports-color@8.1.1))(@types/jest@29.5.5)(jest@29.7.0(@types/node@20.12.11)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@20.12.11)(typescript@5.5.2)))(vitest@3.0.4) '@testing-library/react': specifier: ^14.0.0 version: 14.0.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -1344,7 +1344,7 @@ importers: version: 0.7.12 '@vercel/flags': specifier: ^2.6.0 - version: 2.6.0(next@14.2.21(@babel/core@7.24.7(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.72.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 2.6.0(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.72.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) api-types: specifier: workspace:* version: link:../api-types @@ -1393,7 +1393,7 @@ importers: dependencies: '@mertasan/tailwindcss-variables': specifier: ^2.2.3 - version: 2.7.0(autoprefixer@10.4.16(postcss@8.5.3))(postcss@8.5.3) + version: 2.7.0(autoprefixer@10.4.16(postcss@8.4.38))(postcss@8.4.38) '@radix-ui/colors': specifier: ^0.1.8 version: 0.1.9 @@ -1646,7 +1646,7 @@ importers: version: 0.436.0(react@18.2.0) next: specifier: 'catalog:' - version: 14.2.21(@babel/core@7.24.7(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.72.0) + version: 14.2.21(@babel/core@7.26.9(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.72.0) next-themes: specifier: ^0.3.0 version: 0.3.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -1746,7 +1746,7 @@ importers: version: 3.6.1 '@testing-library/jest-dom': specifier: ^6.1.3 - version: 6.4.6(@jest/globals@29.7.0(supports-color@8.1.1))(@types/jest@29.5.5)(jest@29.7.0(@types/node@20.12.11)(supports-color@8.1.1))(vitest@3.0.4) + version: 6.4.6(@jest/globals@29.7.0(supports-color@8.1.1))(@types/jest@29.5.5)(jest@29.7.0(@types/node@20.12.11)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@20.12.11)(typescript@5.5.2)))(vitest@3.0.4) '@testing-library/react': specifier: ^14.0.0 version: 14.0.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -1834,18 +1834,24 @@ importers: framer-motion: specifier: ^11.1.9 version: 11.11.17(@emotion/is-prop-valid@1.2.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + github-slugger: + specifier: ^2.0.0 + version: 2.0.0 lodash: specifier: '*' version: 4.17.21 lucide-react: specifier: '*' version: 0.436.0(react@18.2.0) + mdast: + specifier: ^3.0.0 + version: 3.0.0 monaco-editor: specifier: '*' version: 0.33.0 next: specifier: 'catalog:' - version: 14.2.21(@babel/core@7.24.7(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.72.0) + version: 14.2.21(@babel/core@7.26.9(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.72.0) next-themes: specifier: '*' version: 0.3.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -1885,9 +1891,15 @@ importers: recharts: specifier: ^2.8.0 version: 2.12.7(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + remark: + specifier: ^15.0.1 + version: 15.0.1(supports-color@8.1.1) remark-gfm: specifier: ^4.0.0 version: 4.0.0(supports-color@8.1.1) + scroll-into-view-if-needed: + specifier: ^3.1.0 + version: 3.1.0 sonner: specifier: ^1.5.0 version: 1.5.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -1903,6 +1915,9 @@ importers: ui: specifier: workspace:* version: link:../ui + unist-util-visit: + specifier: ^5.0.0 + version: 5.0.0 valtio: specifier: '*' version: 1.12.0(@types/react@18.3.3)(react@18.2.0) @@ -1915,7 +1930,7 @@ importers: version: 10.1.0 '@testing-library/jest-dom': specifier: ^6.4.6 - version: 6.4.6(@jest/globals@29.7.0(supports-color@8.1.1))(@types/jest@29.5.5)(jest@29.7.0(@types/node@20.12.11)(supports-color@8.1.1))(vitest@3.0.4) + version: 6.4.6(@jest/globals@29.7.0(supports-color@8.1.1))(@types/jest@29.5.5)(jest@29.7.0(@types/node@20.12.11)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@20.12.11)(typescript@5.5.2)))(vitest@3.0.4) '@testing-library/react': specifier: ^16.0.0 version: 16.0.0(@testing-library/dom@10.1.0)(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -1928,6 +1943,9 @@ importers: '@types/lodash': specifier: ^4.17.5 version: 4.17.5 + '@types/mdast': + specifier: ^3.0.0 + version: 3.0.15 '@types/node': specifier: 'catalog:' version: 20.12.11 @@ -1942,10 +1960,16 @@ importers: version: link:../api-types next-router-mock: specifier: ^0.9.13 - version: 0.9.13(next@14.2.21(@babel/core@7.24.7(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.72.0))(react@18.2.0) + version: 0.9.13(next@14.2.21(@babel/core@7.26.9(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.72.0))(react@18.2.0) typescript: specifier: ~5.5.0 version: 5.5.2 + unified: + specifier: ^11.0.5 + version: 11.0.5 + vfile: + specifier: ^6.0.3 + version: 6.0.3 vitest: specifier: ^3.0.0 version: 3.0.4(@types/node@20.12.11)(@vitest/ui@3.0.4)(jsdom@20.0.3(supports-color@8.1.1))(msw@2.4.11(typescript@5.5.2))(sass@1.72.0)(supports-color@8.1.1)(terser@5.39.0) @@ -6734,6 +6758,9 @@ packages: commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + compute-scroll-into-view@3.1.1: + resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -7507,7 +7534,6 @@ packages: resolution: {integrity: sha512-t0q23FIpvHDTtnORW+bDJziGsal5uh9RJTJ1fyH8drd4lICOoXhJ5pLMUZ5C0VQei6dNmwTzzoTRgMkO9JgHEQ==} peerDependencies: eslint: '>= 5' - bundledDependencies: [] eslint-plugin-import@2.29.1: resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==} @@ -11428,6 +11454,9 @@ packages: resolution: {integrity: sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==} engines: {node: '>=0.10.0'} + scroll-into-view-if-needed@3.1.0: + resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} + section-matter@1.0.0: resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} engines: {node: '>=4'} @@ -14900,11 +14929,11 @@ snapshots: '@types/react': 18.3.3 react: 18.2.0 - '@mertasan/tailwindcss-variables@2.7.0(autoprefixer@10.4.16(postcss@8.5.3))(postcss@8.5.3)': + '@mertasan/tailwindcss-variables@2.7.0(autoprefixer@10.4.16(postcss@8.4.38))(postcss@8.4.38)': dependencies: - autoprefixer: 10.4.16(postcss@8.5.3) + autoprefixer: 10.4.16(postcss@8.4.38) lodash: 4.17.21 - postcss: 8.5.3 + postcss: 8.4.38 '@monaco-editor/loader@1.4.0(monaco-editor@0.33.0)': dependencies: @@ -17374,7 +17403,7 @@ snapshots: lz-string: 1.5.0 pretty-format: 27.5.1 - '@testing-library/jest-dom@6.4.6(@jest/globals@29.7.0(supports-color@8.1.1))(@types/jest@29.5.5)(jest@29.7.0(@types/node@20.12.11)(supports-color@8.1.1))(vitest@3.0.4)': + '@testing-library/jest-dom@6.4.6(@jest/globals@29.7.0(supports-color@8.1.1))(@types/jest@29.5.5)(jest@29.7.0(@types/node@20.12.11)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@20.12.11)(typescript@5.5.2)))(vitest@3.0.4)': dependencies: '@adobe/css-tools': 4.4.0 '@babel/runtime': 7.24.7 @@ -17696,7 +17725,7 @@ snapshots: '@types/hast@3.0.4': dependencies: - '@types/unist': 3.0.3 + '@types/unist': 2.0.8 '@types/hoist-non-react-statics@3.3.2': dependencies: @@ -18022,6 +18051,14 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) + '@vercel/flags@2.6.0(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.72.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + jose: 5.2.1 + optionalDependencies: + next: 14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.72.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + '@vercel/og@0.6.2': dependencies: '@resvg/resvg-wasm': 2.4.0 @@ -18576,16 +18613,6 @@ snapshots: postcss: 8.4.38 postcss-value-parser: 4.2.0 - autoprefixer@10.4.16(postcss@8.5.3): - dependencies: - browserslist: 4.23.3 - caniuse-lite: 1.0.30001651 - fraction.js: 4.3.6 - normalize-range: 0.1.2 - picocolors: 1.0.1 - postcss: 8.5.3 - postcss-value-parser: 4.2.0 - available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.0.0 @@ -19103,6 +19130,8 @@ snapshots: commondir@1.0.1: {} + compute-scroll-into-view@3.1.1: {} + concat-map@0.0.1: {} concat-stream@1.6.2: @@ -23497,7 +23526,7 @@ snapshots: '@contentlayer2/core': 0.5.3(esbuild@0.21.5)(markdown-wasm@1.2.0)(supports-color@8.1.1) '@contentlayer2/utils': 0.5.3 contentlayer2: 0.5.3(esbuild@0.21.5)(markdown-wasm@1.2.0)(supports-color@8.1.1) - next: 14.2.21(@babel/core@7.24.7(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.72.0) + next: 14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.72.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) transitivePeerDependencies: @@ -23526,9 +23555,14 @@ snapshots: next: 14.2.21(@babel/core@7.24.7(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.72.0) react: 18.2.0 + next-router-mock@0.9.13(next@14.2.21(@babel/core@7.26.9(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.72.0))(react@18.2.0): + dependencies: + next: 14.2.21(@babel/core@7.26.9(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.72.0) + react: 18.2.0 + next-seo@6.5.0(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.72.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: - next: 14.2.21(@babel/core@7.24.7(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.72.0) + next: 14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.72.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -23565,6 +23599,62 @@ snapshots: - '@babel/core' - babel-plugin-macros + next@14.2.21(@babel/core@7.26.9(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.72.0): + dependencies: + '@next/env': 14.2.21 + '@swc/helpers': 0.5.5 + busboy: 1.6.0 + caniuse-lite: 1.0.30001695 + graceful-fs: 4.2.11 + postcss: 8.4.31 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + styled-jsx: 5.1.1(@babel/core@7.26.9(supports-color@8.1.1))(react@18.2.0) + optionalDependencies: + '@next/swc-darwin-arm64': 14.2.21 + '@next/swc-darwin-x64': 14.2.21 + '@next/swc-linux-arm64-gnu': 14.2.21 + '@next/swc-linux-arm64-musl': 14.2.21 + '@next/swc-linux-x64-gnu': 14.2.21 + '@next/swc-linux-x64-musl': 14.2.21 + '@next/swc-win32-arm64-msvc': 14.2.21 + '@next/swc-win32-ia32-msvc': 14.2.21 + '@next/swc-win32-x64-msvc': 14.2.21 + '@opentelemetry/api': 1.9.0 + '@playwright/test': 1.49.1 + sass: 1.72.0 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.72.0): + dependencies: + '@next/env': 14.2.21 + '@swc/helpers': 0.5.5 + busboy: 1.6.0 + caniuse-lite: 1.0.30001695 + graceful-fs: 4.2.11 + postcss: 8.4.31 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + styled-jsx: 5.1.1(@babel/core@7.24.7(supports-color@8.1.1))(react@18.2.0) + optionalDependencies: + '@next/swc-darwin-arm64': 14.2.21 + '@next/swc-darwin-x64': 14.2.21 + '@next/swc-linux-arm64-gnu': 14.2.21 + '@next/swc-linux-arm64-musl': 14.2.21 + '@next/swc-linux-x64-gnu': 14.2.21 + '@next/swc-linux-x64-musl': 14.2.21 + '@next/swc-win32-arm64-msvc': 14.2.21 + '@next/swc-win32-ia32-msvc': 14.2.21 + '@next/swc-win32-x64-msvc': 14.2.21 + '@opentelemetry/api': 1.9.0 + '@playwright/test': 1.49.1 + sass: 1.72.0 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + nice-try@1.0.5: {} no-case@3.0.4: @@ -23719,6 +23809,11 @@ snapshots: mitt: 3.0.1 next: 14.2.21(@babel/core@7.24.7(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.72.0) + nuqs@1.19.1(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.72.0)): + dependencies: + mitt: 3.0.1 + next: 14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.72.0) + nwsapi@2.2.16: optional: true @@ -25403,6 +25498,10 @@ snapshots: screenfull@5.2.0: {} + scroll-into-view-if-needed@3.1.0: + dependencies: + compute-scroll-into-view: 3.1.1 + section-matter@1.0.0: dependencies: extend-shallow: 2.0.1 @@ -25896,6 +25995,13 @@ snapshots: optionalDependencies: '@babel/core': 7.24.7(supports-color@8.1.1) + styled-jsx@5.1.1(@babel/core@7.26.9(supports-color@8.1.1))(react@18.2.0): + dependencies: + client-only: 0.0.1 + react: 18.2.0 + optionalDependencies: + '@babel/core': 7.26.9(supports-color@8.1.1) + stylis@4.3.1: {} sucrase@3.34.0: