diff --git a/README.md b/README.md deleted file mode 100644 index 56d3d70b6675..000000000000 --- a/README.md +++ /dev/null @@ -1,45 +0,0 @@ - - -![hbase-logo](https://raw.githubusercontent.com/apache/hbase/master/src/site/resources/images/hbase_logo_with_orca_large.png) - -[Apache HBase](https://hbase.apache.org) is an open-source, distributed, versioned, column-oriented store modeled after Google' [Bigtable](https://research.google.com/archive/bigtable.html): A Distributed Storage System for Structured Data by Chang et al. Just as Bigtable leverages the distributed data storage provided by the Google File System, HBase provides Bigtable-like capabilities on top of [Apache Hadoop](https://hadoop.apache.org/). - -# Getting Start -To get started using HBase, the full documentation for this release can be found under the doc/ directory that accompanies this README. Using a browser, open the docs/index.html to view the project home page (or browse https://hbase.apache.org). The hbase '[book](https://hbase.apache.org/book.html)' has a 'quick start' section and is where you should being your exploration of the hbase project. - -The latest HBase can be downloaded from the [download page](https://hbase.apache.org/downloads.html). - -We use mailing lists to send notice and discuss. The mailing lists and archives are listed [here](http://hbase.apache.org/mail-lists.html) - -We use the #hbase channel on the official [ASF Slack Workspace](https://the-asf.slack.com/) for real time questions and discussions. Please mail dev@hbase.apache.org to request an invite. - -# How to Contribute -The source code can be found at https://hbase.apache.org/source-repository.html - -The HBase issue tracker is at https://hbase.apache.org/issue-tracking.html - -Notice that, the public registration for https://issues.apache.org/ has been disabled due to spam. If you want to contribute to HBase, please visit the [Request a jira account](https://selfserve.apache.org/jira-account.html) page to submit your request. Please make sure to select **hbase** as the '_ASF project you want to file a ticket_' so we can receive your request and process it. - -> **_NOTE:_** we need to process the requests manually so it may take sometime, for example, up to a week, for us to respond to your request. - -# About -Apache HBase is made available under the [Apache License, version 2.0](https://hbase.apache.org/license.html) - -The HBase distribution includes cryptographic software. See the export control notice [here](https://hbase.apache.org/export_control.html). diff --git a/hbase-website/.gitignore b/hbase-website/.gitignore index 2888d6734ed0..eeb7d01c1709 100644 --- a/hbase-website/.gitignore +++ b/hbase-website/.gitignore @@ -34,3 +34,10 @@ lerna-debug.log* # Generated files /app/pages/team/developers.json +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ diff --git a/hbase-website/.source/index.ts b/hbase-website/.source/index.ts new file mode 100644 index 000000000000..eb23d9be856e --- /dev/null +++ b/hbase-website/.source/index.ts @@ -0,0 +1,21 @@ +/// +import { fromConfig } from 'fumadocs-mdx/runtime/vite'; +import type * as Config from '../source.config'; + +export const create = fromConfig(); + +export const docs = { + doc: create.doc("docs", "app/pages/_docs/docs/_mdx", import.meta.glob(["./**/*.mdx"], { + "query": { + "collection": "docs" + }, + "base": "./../app/pages/_docs/docs/_mdx" + })), + meta: create.meta("docs", "app/pages/_docs/docs/_mdx", import.meta.glob(["./**/*.{json,yaml}"], { + "import": "default", + "base": "./../app/pages/_docs/docs/_mdx", + "query": { + "collection": "docs" + } + })) +}; \ No newline at end of file diff --git a/hbase-website/.vite/deps/_metadata.json b/hbase-website/.vite/deps/_metadata.json new file mode 100644 index 000000000000..b32dd284744f --- /dev/null +++ b/hbase-website/.vite/deps/_metadata.json @@ -0,0 +1,8 @@ +{ + "hash": "c3d4a621", + "configHash": "fe8bf0bb", + "lockfileHash": "f94e92f2", + "browserHash": "0081a6ff", + "optimized": {}, + "chunks": {} +} \ No newline at end of file diff --git a/hbase-website/.vite/deps/package.json b/hbase-website/.vite/deps/package.json new file mode 100644 index 000000000000..3dbc1ca591c0 --- /dev/null +++ b/hbase-website/.vite/deps/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/hbase-website/app/app.css b/hbase-website/app/app.css index 30bf9ef10d7e..91b1f11a904a 100644 --- a/hbase-website/app/app.css +++ b/hbase-website/app/app.css @@ -18,7 +18,12 @@ @import "tailwindcss"; @import "tw-animate-css"; -@import "highlight.js/styles/github.css"; +@import 'fumadocs-ui/css/shadcn.css'; +@import 'fumadocs-ui/css/preset.css'; + +@plugin "@tailwindcss/typography" { + className: prose-original; +} @theme { } @@ -179,33 +184,12 @@ } } -/* Code syntax highlighting for dark mode */ -.dark pre code.hljs { - background: oklch(0.2 0 0); - color: oklch(0.85 0 0); -} - -.dark .hljs-comment { - color: oklch(0.55 0 0); -} - -.dark .hljs-keyword { - color: oklch(0.75 0.12 340); -} - -.dark .hljs-string { - color: oklch(0.75 0.1 140); -} - -.dark .hljs-number { - color: oklch(0.75 0.1 100); -} - -.dark .hljs-title { - color: oklch(0.75 0.12 260); -} - -.dark .hljs-name, -.dark .hljs-attribute { - color: oklch(0.7 0.12 200); +/* For rendering a PDF */ +@media print { + #nd-docs-layout { + --fd-sidebar-width: 0px !important; + } + #nd-sidebar { + display: none; + } } diff --git a/hbase-website/app/components/docs/layout/docs/client.tsx b/hbase-website/app/components/docs/layout/docs/client.tsx new file mode 100644 index 000000000000..367a3f548b4f --- /dev/null +++ b/hbase-website/app/components/docs/layout/docs/client.tsx @@ -0,0 +1,157 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + type ComponentProps, + createContext, + type ReactNode, + use, + useEffect, + useMemo, + useState +} from "react"; +import { useSidebar } from "../sidebar/base"; +import { usePathname } from "fumadocs-core/framework"; +import Link from "fumadocs-core/link"; +import type { SidebarTab } from "../sidebar/tabs"; +import { isTabActive } from "../sidebar/tabs/dropdown"; +import { cn } from "@/lib/utils"; + +export const LayoutContext = createContext<{ + isNavTransparent: boolean; +} | null>(null); + +export function LayoutContextProvider({ + navTransparentMode = "none", + children +}: { + navTransparentMode?: "always" | "top" | "none"; + children: ReactNode; +}) { + const isTop = useIsScrollTop({ enabled: navTransparentMode === "top" }) ?? true; + const isNavTransparent = navTransparentMode === "top" ? isTop : navTransparentMode === "always"; + + return ( + ({ + isNavTransparent + }), + [isNavTransparent] + )} + > + {children} + + ); +} + +export function LayoutHeader(props: ComponentProps<"header">) { + const { isNavTransparent } = use(LayoutContext)!; + + return ( +
+ {props.children} +
+ ); +} + +export function LayoutBody({ className, style, children, ...props }: ComponentProps<"div">) { + const { collapsed } = useSidebar(); + + return ( +
+ {children} +
+ ); +} + +export function LayoutTabs({ + options, + ...props +}: ComponentProps<"div"> & { + options: SidebarTab[]; +}) { + const pathname = usePathname(); + const selected = useMemo(() => { + return options.findLast((option) => isTabActive(option, pathname)); + }, [options, pathname]); + + return ( +
+ {options.map((option, i) => ( + + {option.title} + + ))} +
+ ); +} + +export function useIsScrollTop({ enabled = true }: { enabled?: boolean }) { + const [isTop, setIsTop] = useState(); + + useEffect(() => { + if (!enabled) return; + + const listener = () => { + setIsTop(window.scrollY < 10); + }; + + listener(); + window.addEventListener("scroll", listener); + return () => { + window.removeEventListener("scroll", listener); + }; + }, [enabled]); + + return isTop; +} diff --git a/hbase-website/app/components/docs/layout/docs/index.tsx b/hbase-website/app/components/docs/layout/docs/index.tsx new file mode 100644 index 000000000000..02e682574044 --- /dev/null +++ b/hbase-website/app/components/docs/layout/docs/index.tsx @@ -0,0 +1,285 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type * as PageTree from "fumadocs-core/page-tree"; +import { type ComponentProps, type HTMLAttributes, type ReactNode, useMemo } from "react"; +import { Languages, Sidebar as SidebarIcon } from "lucide-react"; +import { buttonVariants } from "../../../../ui/button"; +import { + Sidebar, + SidebarCollapseTrigger, + SidebarContent, + SidebarDrawer, + SidebarLinkItem, + SidebarPageTree, + SidebarTrigger, + SidebarViewport +} from "./sidebar"; +import { type BaseLayoutProps, renderTitleNav, resolveLinkItems } from "../shared"; +import { LinkItem } from "../link-item"; +import { LanguageToggle, LanguageToggleText } from "../language-toggle"; +import { LayoutBody, LayoutContextProvider, LayoutHeader, LayoutTabs } from "./client"; +import { TreeContextProvider } from "fumadocs-ui/contexts/tree"; +import { ThemeToggle } from "../theme-toggle"; +import { LargeSearchToggle, SearchToggle } from "../search-toggle"; +import { getSidebarTabs, type GetSidebarTabsOptions } from "../sidebar/tabs"; +import type { SidebarPageTreeComponents } from "../sidebar/page-tree"; +import { SidebarTabsDropdown, type SidebarTabWithProps } from "../sidebar/tabs/dropdown"; +import { cn } from "@/lib/utils"; + +export interface DocsLayoutProps extends BaseLayoutProps { + tree: PageTree.Root; + + sidebar?: SidebarOptions; + + tabMode?: "top" | "auto"; + + /** + * Props for the `div` container + */ + containerProps?: HTMLAttributes; + + shouldRenderPageTree?: boolean; +} + +interface SidebarOptions + extends ComponentProps<"aside">, + Pick, "defaultOpenLevel" | "prefetch"> { + enabled?: boolean; + component?: ReactNode; + components?: Partial; + + /** + * Root Toggle options + */ + tabs?: SidebarTabWithProps[] | GetSidebarTabsOptions | false; + + banner?: ReactNode; + footer?: ReactNode; + + /** + * Support collapsing the sidebar on desktop mode + * + * @defaultValue true + */ + collapsible?: boolean; +} + +export function DocsLayout({ + nav: { transparentMode, ...nav } = {}, + sidebar: { + tabs: sidebarTabs, + enabled: sidebarEnabled = true, + defaultOpenLevel, + prefetch, + ...sidebarProps + } = {}, + searchToggle = {}, + themeSwitch = {}, + tabMode = "auto", + i18n = false, + children, + tree, + shouldRenderPageTree = true, + ...props +}: DocsLayoutProps) { + const tabs = useMemo(() => { + if (Array.isArray(sidebarTabs)) { + return sidebarTabs; + } + if (typeof sidebarTabs === "object") { + return getSidebarTabs(tree, sidebarTabs); + } + if (sidebarTabs !== false) { + return getSidebarTabs(tree); + } + return []; + }, [tree, sidebarTabs]); + const links = resolveLinkItems(props); + + function sidebar() { + const { footer, banner, collapsible = true, component, components, ...rest } = sidebarProps; + if (component) return component; + + const iconLinks = links.filter((item) => item.type === "icon"); + const viewport = ( + + {links + .filter((v) => v.type !== "icon") + .map((item, i, list) => ( + + ))} + {shouldRenderPageTree && } + + ); + + return ( + <> + +
+
+ {renderTitleNav(nav, { + className: "inline-flex text-[0.9375rem] items-center gap-2.5 font-medium me-auto" + })} + {nav.children} + {collapsible && ( + + + + )} +
+ {searchToggle.enabled !== false && + (searchToggle.components?.lg ?? )} + {tabs.length > 0 && tabMode === "auto" && } + {banner} +
+ {viewport} + {(i18n || iconLinks.length > 0 || themeSwitch?.enabled !== false || footer) && ( +
+
+ {i18n && ( + + + + )} + {iconLinks.map((item, i) => ( + + {item.icon} + + ))} + {themeSwitch.enabled !== false && + (themeSwitch.component ?? ( + + ))} +
+ {footer} +
+ )} +
+ +
+
+
+ {iconLinks.map((item, i) => ( + + {item.icon} + + ))} +
+ {i18n && ( + + + + + )} + {themeSwitch.enabled !== false && + (themeSwitch.component ?? )} + + + +
+ {tabs.length > 0 && } + {banner} +
+ {viewport} +
{footer}
+
+ + ); + } + + return ( + + + + + {nav.enabled !== false && + (nav.component ?? ( + + {renderTitleNav(nav, { + className: "inline-flex items-center gap-2.5 font-semibold" + })} +
{nav.children}
+ {searchToggle.enabled !== false && + (searchToggle.components?.sm ?? ( + + ))} + {sidebarEnabled && ( + + + + )} +
+ ))} + {sidebarEnabled && sidebar()} + {tabMode === "top" && tabs.length > 0 && ( + + )} + {children} +
+
+
+
+ ); +} diff --git a/hbase-website/app/components/docs/layout/docs/page/client.tsx b/hbase-website/app/components/docs/layout/docs/page/client.tsx new file mode 100644 index 000000000000..4d47579c1035 --- /dev/null +++ b/hbase-website/app/components/docs/layout/docs/page/client.tsx @@ -0,0 +1,384 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + type ComponentProps, + createContext, + Fragment, + use, + useEffect, + useEffectEvent, + useMemo, + useRef, + useState +} from "react"; +import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"; +import Link from "fumadocs-core/link"; +import { useI18n } from "fumadocs-ui/contexts/i18n"; +import { useTreeContext, useTreePath } from "fumadocs-ui/contexts/tree"; +import type * as PageTree from "fumadocs-core/page-tree"; +import { usePathname } from "fumadocs-core/framework"; +import { type BreadcrumbOptions, getBreadcrumbItemsFromPath } from "fumadocs-core/breadcrumb"; +import { isActive } from "../../../../../lib/urls"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../../../ui/collapsible"; +import { useTOCItems } from "../../../toc"; +import { useActiveAnchor } from "fumadocs-core/toc"; +import { LayoutContext } from "../client"; +import { cn } from "@/lib/utils"; + +const TocPopoverContext = createContext<{ + open: boolean; + setOpen: (open: boolean) => void; +} | null>(null); + +export function PageTOCPopover({ className, children, ...rest }: ComponentProps<"div">) { + const ref = useRef(null); + const [open, setOpen] = useState(false); + const { isNavTransparent } = use(LayoutContext)!; + + const onClick = useEffectEvent((e: Event) => { + if (!open) return; + + if (ref.current && !ref.current.contains(e.target as HTMLElement)) setOpen(false); + }); + + useEffect(() => { + window.addEventListener("click", onClick); + + return () => { + window.removeEventListener("click", onClick); + }; + }, [onClick]); + + return ( + ({ + open, + setOpen + }), + [setOpen, open] + )} + > + +
+ {children} +
+
+
+ ); +} + +export function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<"button">) { + const { text } = useI18n(); + const { open } = use(TocPopoverContext)!; + const items = useTOCItems(); + const active = useActiveAnchor(); + const selected = useMemo( + () => items.findIndex((item) => active === item.url.slice(1)), + [items, active] + ); + const path = useTreePath().at(-1); + const showItem = selected !== -1 && !open; + + return ( + + + + + {path?.name ?? text.toc} + + + {items[selected]?.title} + + + + + ); +} + +interface ProgressCircleProps extends Omit, "strokeWidth"> { + value: number; + strokeWidth?: number; + size?: number; + min?: number; + max?: number; +} + +function clamp(input: number, min: number, max: number): number { + if (input < min) return min; + if (input > max) return max; + return input; +} + +function ProgressCircle({ + value, + strokeWidth = 2, + size = 24, + min = 0, + max = 100, + ...restSvgProps +}: ProgressCircleProps) { + const normalizedValue = clamp(value, min, max); + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const progress = (normalizedValue / max) * circumference; + const circleProps = { + cx: size / 2, + cy: size / 2, + r: radius, + fill: "none", + strokeWidth + }; + + return ( + + + + + ); +} + +export function PageTOCPopoverContent(props: ComponentProps<"div">) { + return ( + + {props.children} + + ); +} + +export function PageLastUpdate({ + date: value, + ...props +}: Omit, "children"> & { date: Date }) { + const { text } = useI18n(); + const [date, setDate] = useState(""); + + useEffect(() => { + // to the timezone of client + setDate(value.toLocaleDateString()); + }, [value]); + + return ( +

+ {text.lastUpdate} {date} +

+ ); +} + +type Item = Pick; +export interface FooterProps extends ComponentProps<"div"> { + /** + * Items including information for the next and previous page + */ + items?: { + previous?: Item; + next?: Item; + }; +} + +export function PageFooter({ items, children, className, ...props }: FooterProps) { + const footerList = useFooterItems(); + const pathname = usePathname(); + const { previous, next } = useMemo(() => { + if (items) return items; + + const idx = footerList.findIndex((item) => isActive(item.url, pathname, false)); + + if (idx === -1) return {}; + return { + previous: footerList[idx - 1], + next: footerList[idx + 1] + }; + }, [footerList, items, pathname]); + + return ( + <> +
+ {previous && } + {next && } +
+ {children} + + ); +} + +function FooterItem({ item, index }: { item: Item; index: 0 | 1 }) { + const { text } = useI18n(); + const Icon = index === 0 ? ChevronLeft : ChevronRight; + + return ( + +
+ +

{item.name}

+
+

+ {item.description ?? (index === 0 ? text.previousPage : text.nextPage)} +

+ + ); +} + +export type BreadcrumbProps = BreadcrumbOptions & ComponentProps<"div">; + +export function PageBreadcrumb({ + includeRoot, + includeSeparator, + includePage, + ...props +}: BreadcrumbProps) { + const path = useTreePath(); + const { root } = useTreeContext(); + const items = useMemo(() => { + return getBreadcrumbItemsFromPath(root, path, { + includePage, + includeSeparator, + includeRoot + }); + }, [includePage, includeRoot, includeSeparator, path, root]); + + if (items.length === 0) return null; + + return ( +
+ {items.map((item, i) => { + const className = cn("truncate", i === items.length - 1 && "text-fd-primary font-medium"); + + return ( + + {i !== 0 && } + {item.url ? ( + + {item.name} + + ) : ( + {item.name} + )} + + ); + })} +
+ ); +} + +const footerCache = new Map(); + +/** + * @returns a list of page tree items (linear), that you can obtain footer items + */ +export function useFooterItems(): PageTree.Item[] { + const { root } = useTreeContext(); + const cached = footerCache.get(root.$id); + if (cached) return cached; + + const list: PageTree.Item[] = []; + function onNode(node: PageTree.Node) { + if (node.type === "folder") { + if (node.index) onNode(node.index); + for (const child of node.children) onNode(child); + } else if (node.type === "page" && !node.external) { + list.push(node); + } + } + + for (const child of root.children) onNode(child); + footerCache.set(root.$id, list); + return list; +} diff --git a/hbase-website/app/components/docs/layout/docs/page/index.tsx b/hbase-website/app/components/docs/layout/docs/page/index.tsx new file mode 100644 index 000000000000..8f6d2a7756b2 --- /dev/null +++ b/hbase-website/app/components/docs/layout/docs/page/index.tsx @@ -0,0 +1,246 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { ComponentProps, ReactNode } from "react"; +import { buttonVariants } from "../../../../../ui/button"; +import { Edit, Text } from "lucide-react"; +import { I18nLabel } from "fumadocs-ui/contexts/i18n"; +import { + type BreadcrumbProps, + type FooterProps, + PageBreadcrumb, + PageFooter, + PageTOCPopover, + PageTOCPopoverContent, + PageTOCPopoverTrigger +} from "./client"; +import type { AnchorProviderProps, TOCItemType } from "fumadocs-core/toc"; +import * as TocDefault from "../../../toc/default"; +import * as TocClerk from "../../../toc/clerk"; +import { TOCProvider, TOCScrollArea } from "../../../toc"; +import { cn } from "@/lib/utils"; + +interface BreadcrumbOptions extends BreadcrumbProps { + enabled: boolean; + component: ReactNode; +} + +interface FooterOptions extends FooterProps { + enabled: boolean; + component: ReactNode; +} + +export interface DocsPageProps { + toc?: TOCItemType[]; + tableOfContent?: Partial; + tableOfContentPopover?: Partial; + + /** + * Extend the page to fill all available space + * + * @defaultValue false + */ + full?: boolean; + + /** + * Replace or disable breadcrumb + */ + breadcrumb?: Partial; + + /** + * Footer navigation, located under the page body. + * + * You can specify `footer.children` to add extra components under the footer. + */ + footer?: Partial; + + children?: ReactNode; + + /** + * Apply class names to the `#nd-page` container. + */ + className?: string; +} + +type TableOfContentOptions = Pick & { + /** + * Custom content in TOC container, before the main TOC + */ + header?: ReactNode; + + /** + * Custom content in TOC container, after the main TOC + */ + footer?: ReactNode; + + enabled: boolean; + component: ReactNode; + + /** + * @defaultValue 'normal' + */ + style?: "normal" | "clerk"; +}; + +type TableOfContentPopoverOptions = Omit; + +export function DocsPage({ + breadcrumb: { enabled: breadcrumbEnabled = true, component: breadcrumb, ...breadcrumbProps } = {}, + footer: { enabled: footerEnabled, component: footerReplace, ...footerProps } = {}, + full = false, + tableOfContentPopover: { + enabled: tocPopoverEnabled, + component: tocPopover, + ...tocPopoverOptions + } = {}, + tableOfContent: { enabled: tocEnabled, component: tocReplace, ...tocOptions } = {}, + toc = [], + children, + className +}: DocsPageProps) { + // disable TOC on full mode, you can still enable it with `enabled` option. + tocEnabled ??= + !full && (toc.length > 0 || tocOptions.footer !== undefined || tocOptions.header !== undefined); + + tocPopoverEnabled ??= + toc.length > 0 || + tocPopoverOptions.header !== undefined || + tocPopoverOptions.footer !== undefined; + + let wrapper = (children: ReactNode) => children; + + if (tocEnabled || tocPopoverEnabled) { + wrapper = (children) => ( + + {children} + + ); + } + + return wrapper( + <> + {tocPopoverEnabled && + (tocPopover ?? ( + + + + {tocPopoverOptions.header} + + {tocPopoverOptions.style === "clerk" ? ( + + ) : ( + + )} + + {tocPopoverOptions.footer} + + + ))} +
+ {breadcrumbEnabled && (breadcrumb ?? )} + {children} + {footerEnabled !== false && (footerReplace ?? )} +
+ {tocEnabled && + (tocReplace ?? ( +
+ {tocOptions.header} +

+ + +

+ + {tocOptions.style === "clerk" ? : } + + {tocOptions.footer} +
+ ))} + + ); +} + +export function EditOnGitHub(props: ComponentProps<"a">) { + return ( + + {props.children ?? ( + <> + + + + )} + + ); +} + +/** + * Add typography styles + */ +export function DocsBody({ children, className, ...props }: ComponentProps<"div">) { + return ( +
+ {children} +
+ ); +} + +export function DocsDescription({ children, className, ...props }: ComponentProps<"p">) { + // Don't render if no description provided + if (children === undefined) return null; + + return ( +

+ {children} +

+ ); +} + +export function DocsTitle({ children, className, ...props }: ComponentProps<"h1">) { + return ( +

+ {children} +

+ ); +} + +export { PageLastUpdate, PageBreadcrumb } from "./client"; diff --git a/hbase-website/app/components/docs/layout/docs/sidebar.tsx b/hbase-website/app/components/docs/layout/docs/sidebar.tsx new file mode 100644 index 000000000000..a8879202a129 --- /dev/null +++ b/hbase-website/app/components/docs/layout/docs/sidebar.tsx @@ -0,0 +1,264 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import * as Base from "../sidebar/base"; +import { type ComponentProps, useRef } from "react"; +import { cva } from "class-variance-authority"; +import { createPageTreeRenderer } from "../sidebar/page-tree"; +import { createLinkItemRenderer } from "../sidebar/link-item"; +import { buttonVariants } from "../../../../ui/button"; +import { SearchToggle } from "../search-toggle"; +import { Sidebar as SidebarIcon } from "lucide-react"; +import { cn, mergeRefs } from "@/lib/utils"; + +const itemVariants = cva( + "relative flex flex-row items-center gap-2 rounded-lg p-2 text-start text-fd-muted-foreground wrap-anywhere [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + link: "transition-colors hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80 hover:transition-none data-[active=true]:bg-fd-primary/10 data-[active=true]:text-fd-primary data-[active=true]:hover:transition-colors", + button: + "transition-colors hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80 hover:transition-none" + }, + highlight: { + true: "data-[active=true]:before:content-[''] data-[active=true]:before:bg-fd-primary data-[active=true]:before:absolute data-[active=true]:before:w-px data-[active=true]:before:inset-y-2.5 data-[active=true]:before:start-2.5" + } + } + } +); + +function getItemOffset(depth: number) { + return `calc(${2 + 3 * depth} * var(--spacing))`; +} + +export { + SidebarProvider as Sidebar, + SidebarFolder, + SidebarCollapseTrigger, + SidebarViewport, + SidebarTrigger +} from "../sidebar/base"; + +export function SidebarContent({ + ref: refProp, + className, + children, + isSearchToggleEnabled = true, + ...props +}: ComponentProps<"aside"> & { isSearchToggleEnabled?: boolean }) { + const ref = useRef(null); + + return ( + + {({ collapsed, hovered, ref: asideRef, ...rest }) => ( + <> +
+ {collapsed &&
} + +
+
+ + + + {isSearchToggleEnabled && } +
+ + )} + + ); +} + +export function SidebarDrawer({ + children, + className, + ...props +}: ComponentProps) { + return ( + <> + + + {children} + + + ); +} + +export function SidebarSeparator({ className, style, children, ...props }: ComponentProps<"p">) { + const depth = Base.useFolderDepth(); + + return ( + + {children} + + ); +} + +export function SidebarItem({ + className, + style, + children, + ...props +}: ComponentProps) { + const depth = Base.useFolderDepth(); + + return ( + = 1 }), className)} + style={{ + paddingInlineStart: getItemOffset(depth), + ...style + }} + {...props} + > + {children} + + ); +} + +export function SidebarFolderTrigger({ + className, + style, + ...props +}: ComponentProps) { + const { depth, collapsible } = Base.useFolder()!; + + return ( + + {props.children} + + ); +} + +export function SidebarFolderLink({ + className, + style, + ...props +}: ComponentProps) { + const depth = Base.useFolderDepth(); + + return ( + 1 }), "w-full", className)} + style={{ + paddingInlineStart: getItemOffset(depth - 1), + ...style + }} + {...props} + > + {props.children} + + ); +} + +export function SidebarFolderContent({ + className, + children, + ...props +}: ComponentProps) { + const depth = Base.useFolderDepth(); + + return ( + + {children} + + ); +} + +export const SidebarPageTree = createPageTreeRenderer({ + SidebarFolder: Base.SidebarFolder, + SidebarFolderContent, + SidebarFolderLink, + SidebarFolderTrigger, + SidebarItem, + SidebarSeparator +}); + +export const SidebarLinkItem = createLinkItemRenderer({ + SidebarFolder: Base.SidebarFolder, + SidebarFolderContent, + SidebarFolderLink, + SidebarFolderTrigger, + SidebarItem +}); diff --git a/hbase-website/app/components/docs/layout/language-toggle.tsx b/hbase-website/app/components/docs/layout/language-toggle.tsx new file mode 100644 index 000000000000..0427f62d9702 --- /dev/null +++ b/hbase-website/app/components/docs/layout/language-toggle.tsx @@ -0,0 +1,77 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { ComponentProps } from "react"; +import { useI18n } from "fumadocs-ui/contexts/i18n"; +import { Popover, PopoverContent, PopoverTrigger } from "../../../ui/popover"; +import { buttonVariants } from "../../../ui/button"; +import { cn } from "@/lib/utils"; + +export type LanguageSelectProps = ComponentProps<"button">; + +export function LanguageToggle(props: LanguageSelectProps): React.ReactElement { + const context = useI18n(); + if (!context.locales) throw new Error("Missing ``"); + + return ( + + + {props.children} + + +

+ {context.text.chooseLanguage} +

+ {context.locales.map((item) => ( + + ))} +
+
+ ); +} + +export function LanguageToggleText(props: ComponentProps<"span">) { + const context = useI18n(); + const text = context.locales?.find((item) => item.locale === context.locale)?.name; + + return {text}; +} diff --git a/hbase-website/app/components/docs/layout/link-item.tsx b/hbase-website/app/components/docs/layout/link-item.tsx new file mode 100644 index 000000000000..132ef1ffa4c2 --- /dev/null +++ b/hbase-website/app/components/docs/layout/link-item.tsx @@ -0,0 +1,128 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { ComponentProps, ReactNode } from "react"; +import { usePathname } from "fumadocs-core/framework"; +import { isActive } from "../../../lib/urls"; +import Link from "fumadocs-core/link"; + +interface Filterable { + /** + * Restrict where the item is displayed + * + * @defaultValue 'all' + */ + on?: "menu" | "nav" | "all"; +} + +interface WithHref { + url: string; + /** + * When the item is marked as active + * + * @defaultValue 'url' + */ + active?: "url" | "nested-url" | "none"; + external?: boolean; +} + +export interface MainItemType extends WithHref, Filterable { + type?: "main"; + icon?: ReactNode; + text: ReactNode; + description?: ReactNode; +} + +export interface IconItemType extends WithHref, Filterable { + type: "icon"; + /** + * `aria-label` of icon button + */ + label?: string; + icon: ReactNode; + text: ReactNode; + /** + * @defaultValue true + */ + secondary?: boolean; +} + +export interface ButtonItemType extends WithHref, Filterable { + type: "button"; + icon?: ReactNode; + text: ReactNode; + /** + * @defaultValue false + */ + secondary?: boolean; +} + +export interface MenuItemType extends Partial, Filterable { + type: "menu"; + icon?: ReactNode; + text: ReactNode; + + items: ( + | (MainItemType & { + /** + * Options when displayed on navigation menu + */ + menu?: ComponentProps<"a"> & { + banner?: ReactNode; + }; + }) + | CustomItemType + )[]; + + /** + * @defaultValue false + */ + secondary?: boolean; +} + +export interface CustomItemType extends Filterable { + type: "custom"; + /** + * @defaultValue false + */ + secondary?: boolean; + children: ReactNode; +} + +export type LinkItemType = + | MainItemType + | IconItemType + | ButtonItemType + | MenuItemType + | CustomItemType; + +export function LinkItem({ + ref, + item, + ...props +}: Omit, "href"> & { item: WithHref }) { + const pathname = usePathname(); + const activeType = item.active ?? "url"; + const active = activeType !== "none" && isActive(item.url, pathname, activeType === "nested-url"); + + return ( + + {props.children} + + ); +} diff --git a/hbase-website/app/components/docs/layout/search-toggle.tsx b/hbase-website/app/components/docs/layout/search-toggle.tsx new file mode 100644 index 000000000000..2ed63d1f6dbc --- /dev/null +++ b/hbase-website/app/components/docs/layout/search-toggle.tsx @@ -0,0 +1,94 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { ComponentProps } from "react"; +import { Search } from "lucide-react"; +import { useSearchContext } from "fumadocs-ui/contexts/search"; +import { useI18n } from "fumadocs-ui/contexts/i18n"; +import { type ButtonProps, buttonVariants } from "../../../ui/button"; +import { cn } from "@/lib/utils"; + +interface SearchToggleProps extends Omit, "color">, ButtonProps { + hideIfDisabled?: boolean; +} + +export function SearchToggle({ + hideIfDisabled, + size = "icon-sm", + variant = "ghost", + ...props +}: SearchToggleProps) { + const { setOpenSearch, enabled } = useSearchContext(); + if (hideIfDisabled && !enabled) return null; + + return ( + + ); +} + +export function LargeSearchToggle({ + hideIfDisabled, + ...props +}: ComponentProps<"button"> & { + hideIfDisabled?: boolean; +}) { + const { enabled, hotKey, setOpenSearch } = useSearchContext(); + const { text } = useI18n(); + if (hideIfDisabled && !enabled) return null; + + return ( + + ); +} diff --git a/hbase-website/app/components/docs/layout/shared.tsx b/hbase-website/app/components/docs/layout/shared.tsx new file mode 100644 index 000000000000..130221692a5b --- /dev/null +++ b/hbase-website/app/components/docs/layout/shared.tsx @@ -0,0 +1,120 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { ComponentProps, ReactNode } from "react"; +import type { I18nConfig } from "fumadocs-core/i18n"; +import type { LinkItemType } from "./link-item"; +import Link from "fumadocs-core/link"; + +export interface NavOptions { + enabled: boolean; + component: ReactNode; + + title?: ReactNode | ((props: ComponentProps<"a">) => ReactNode); + + /** + * Redirect url of title + * @defaultValue '/' + */ + url?: string; + + /** + * Use transparent background + * + * @defaultValue none + */ + transparentMode?: "always" | "top" | "none"; + + children?: ReactNode; +} + +export interface BaseLayoutProps { + themeSwitch?: { + enabled?: boolean; + component?: ReactNode; + mode?: "light-dark" | "light-dark-system"; + }; + + searchToggle?: Partial<{ + enabled: boolean; + components: Partial<{ + sm: ReactNode; + lg: ReactNode; + }>; + }>; + + /** + * I18n options + * + * @defaultValue false + */ + i18n?: boolean | I18nConfig; + + /** + * GitHub url + */ + githubUrl?: string; + + links?: LinkItemType[]; + /** + * Replace or disable navbar + */ + nav?: Partial; + + children?: ReactNode; +} + +/** + * Get link items with shortcuts + */ +export function resolveLinkItems({ + links = [], + githubUrl +}: Pick): LinkItemType[] { + const result = [...links]; + + if (githubUrl) + result.push({ + type: "icon", + url: githubUrl, + text: "Github", + label: "GitHub", + icon: ( + + + + ), + external: true + }); + + return result; +} + +export function renderTitleNav( + { title, url = "/" }: Partial, + props: ComponentProps<"a"> +) { + if (typeof title === "function") return title({ href: url, ...props }); + return ( + + {title} + + ); +} + +export type * from "./link-item"; diff --git a/hbase-website/app/components/docs/layout/sidebar/base.tsx b/hbase-website/app/components/docs/layout/sidebar/base.tsx new file mode 100644 index 000000000000..bad26c16fe8d --- /dev/null +++ b/hbase-website/app/components/docs/layout/sidebar/base.tsx @@ -0,0 +1,423 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { ChevronDown, ExternalLink } from "lucide-react"; +import { + type ComponentProps, + createContext, + type PointerEvent, + type ReactNode, + type RefObject, + use, + useEffect, + useMemo, + useRef, + useState +} from "react"; +import Link, { type LinkProps } from "fumadocs-core/link"; +import { useOnChange } from "fumadocs-core/utils/use-on-change"; +import { ScrollArea, type ScrollAreaProps, ScrollViewport } from "../../../../ui/scroll-area"; +import { isActive } from "../../../../lib/urls"; +import { + Collapsible, + CollapsibleContent, + type CollapsibleContentProps, + CollapsibleTrigger, + type CollapsibleTriggerProps +} from "../../../../ui/collapsible"; +import { useMediaQuery } from "fumadocs-core/utils/use-media-query"; +import { Presence } from "@radix-ui/react-presence"; +import scrollIntoView from "scroll-into-view-if-needed"; +import { usePathname } from "fumadocs-core/framework"; +import { cn } from "@/lib/utils"; + +interface SidebarContext { + open: boolean; + setOpen: React.Dispatch>; + collapsed: boolean; + setCollapsed: React.Dispatch>; + + /** + * When set to false, don't close the sidebar when navigate to another page + */ + closeOnRedirect: RefObject; + defaultOpenLevel: number; + prefetch?: boolean; + mode: Mode; +} + +export interface SidebarProviderProps { + /** + * Open folders by default if their level is lower or equal to a specific level + * (Starting from 1) + * + * @defaultValue 0 + */ + defaultOpenLevel?: number; + + /** + * Prefetch links, default behaviour depends on your React.js framework. + */ + prefetch?: boolean; + + children?: ReactNode; +} + +type Mode = "drawer" | "full"; + +const SidebarContext = createContext(null); + +const FolderContext = createContext<{ + open: boolean; + setOpen: React.Dispatch>; + depth: number; + collapsible: boolean; +} | null>(null); + +export function SidebarProvider({ + defaultOpenLevel = 0, + prefetch, + children +}: SidebarProviderProps) { + const closeOnRedirect = useRef(true); + const [open, setOpen] = useState(false); + const [collapsed, setCollapsed] = useState(false); + const pathname = usePathname(); + const mode: Mode = useMediaQuery("(width < 768px)") ? "drawer" : "full"; + + useOnChange(pathname, () => { + if (closeOnRedirect.current) { + setOpen(false); + } + closeOnRedirect.current = true; + }); + + return ( + ({ + open, + setOpen, + collapsed, + setCollapsed, + closeOnRedirect, + defaultOpenLevel, + prefetch, + mode + }), + [open, collapsed, defaultOpenLevel, prefetch, mode] + )} + > + {children} + + ); +} + +export function useSidebar(): SidebarContext { + const ctx = use(SidebarContext); + if (!ctx) + throw new Error( + "Missing SidebarContext, make sure you have wrapped the component in and the context is available." + ); + + return ctx; +} + +export function useFolder() { + return use(FolderContext); +} + +export function useFolderDepth() { + return use(FolderContext)?.depth ?? 0; +} + +export function SidebarContent({ + children +}: { + children: (state: { + ref: RefObject; + collapsed: boolean; + hovered: boolean; + onPointerEnter: (event: PointerEvent) => void; + onPointerLeave: (event: PointerEvent) => void; + }) => ReactNode; +}) { + const { collapsed, mode } = useSidebar(); + const [hover, setHover] = useState(false); + const ref = useRef(null); + const timerRef = useRef(0); + + useOnChange(collapsed, () => { + if (collapsed) setHover(false); + }); + + if (mode !== "full") return; + + function shouldIgnoreHover(e: PointerEvent): boolean { + const element = ref.current; + if (!element) return true; + + return !collapsed || e.pointerType === "touch" || element.getAnimations().length > 0; + } + + return children({ + ref, + collapsed, + hovered: hover, + onPointerEnter(e) { + if (shouldIgnoreHover(e)) return; + window.clearTimeout(timerRef.current); + setHover(true); + }, + onPointerLeave(e) { + if (shouldIgnoreHover(e)) return; + window.clearTimeout(timerRef.current); + + timerRef.current = window.setTimeout( + () => setHover(false), + // if mouse is leaving the viewport, add a close delay + Math.min(e.clientX, document.body.clientWidth - e.clientX) > 100 ? 0 : 500 + ); + } + }); +} + +export function SidebarDrawerOverlay(props: ComponentProps<"div">) { + const { open, setOpen, mode } = useSidebar(); + + if (mode !== "drawer") return; + return ( + +
setOpen(false)} {...props} /> + + ); +} + +export function SidebarDrawerContent({ className, children, ...props }: ComponentProps<"aside">) { + const { open, mode } = useSidebar(); + const state = open ? "open" : "closed"; + + if (mode !== "drawer") return; + return ( + + {({ present }) => ( + + )} + + ); +} + +export function SidebarViewport(props: ScrollAreaProps) { + return ( + + + {props.children} + + + ); +} + +export function SidebarSeparator(props: ComponentProps<"p">) { + const depth = useFolderDepth(); + return ( +

+ {props.children} +

+ ); +} + +export function SidebarItem({ + icon, + children, + ...props +}: LinkProps & { + icon?: ReactNode; +}) { + const pathname = usePathname(); + const ref = useRef(null); + const { prefetch } = useSidebar(); + const active = props.href !== undefined && isActive(props.href, pathname, false); + + useAutoScroll(active, ref); + + return ( + + {icon ?? (props.external ? : null)} + {children} + + ); +} + +export function SidebarFolder({ + defaultOpen: defaultOpenProp, + collapsible = true, + active = false, + children, + ...props +}: ComponentProps<"div"> & { + active?: boolean; + defaultOpen?: boolean; + collapsible?: boolean; +}) { + const { defaultOpenLevel } = useSidebar(); + const depth = useFolderDepth() + 1; + const defaultOpen = + collapsible === false || active || (defaultOpenProp ?? defaultOpenLevel >= depth); + const [open, setOpen] = useState(defaultOpen); + + useOnChange(defaultOpen, (v) => { + if (v) setOpen(v); + }); + + return ( + + ({ open, setOpen, depth, collapsible }), [collapsible, depth, open])} + > + {children} + + + ); +} + +export function SidebarFolderTrigger({ children, ...props }: CollapsibleTriggerProps) { + const { open, collapsible } = use(FolderContext)!; + + if (collapsible) { + return ( + + {children} + + + ); + } + + return
)}>{children}
; +} + +export function SidebarFolderLink({ children, ...props }: LinkProps) { + const ref = useRef(null); + const { open, setOpen, collapsible } = use(FolderContext)!; + const { prefetch } = useSidebar(); + const pathname = usePathname(); + const active = props.href !== undefined && isActive(props.href, pathname, false); + + useAutoScroll(active, ref); + + return ( + { + if (!collapsible) return; + + if (e.target instanceof Element && e.target.matches("[data-icon], [data-icon] *")) { + setOpen(!open); + e.preventDefault(); + } else { + setOpen(active ? !open : true); + } + }} + prefetch={prefetch} + {...props} + > + {children} + {collapsible && ( + + )} + + ); +} + +export function SidebarFolderContent(props: CollapsibleContentProps) { + return {props.children}; +} + +export function SidebarTrigger({ children, ...props }: ComponentProps<"button">) { + const { setOpen } = useSidebar(); + + return ( + + ); +} + +export function SidebarCollapseTrigger(props: ComponentProps<"button">) { + const { collapsed, setCollapsed } = useSidebar(); + + return ( + + ); +} + +/** + * scroll to the element if `active` is true + */ +export function useAutoScroll(active: boolean, ref: RefObject) { + const { mode } = useSidebar(); + + useEffect(() => { + if (active && ref.current) { + scrollIntoView(ref.current, { + boundary: document.getElementById(mode === "drawer" ? "nd-sidebar-mobile" : "nd-sidebar"), + scrollMode: "if-needed" + }); + } + }, [active, mode, ref]); +} diff --git a/hbase-website/app/components/docs/layout/sidebar/link-item.tsx b/hbase-website/app/components/docs/layout/sidebar/link-item.tsx new file mode 100644 index 000000000000..eace0fec4087 --- /dev/null +++ b/hbase-website/app/components/docs/layout/sidebar/link-item.tsx @@ -0,0 +1,78 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { HTMLAttributes } from "react"; +import type * as Base from "./base"; +import type { LinkItemType } from "../link-item"; + +type InternalComponents = Pick< + typeof Base, + | "SidebarFolder" + | "SidebarFolderLink" + | "SidebarFolderContent" + | "SidebarFolderTrigger" + | "SidebarItem" +>; + +export function createLinkItemRenderer({ + SidebarFolder, + SidebarFolderContent, + SidebarFolderLink, + SidebarFolderTrigger, + SidebarItem +}: InternalComponents) { + /** + * Render sidebar items from page tree + */ + return function SidebarLinkItem({ + item, + ...props + }: HTMLAttributes & { + item: Exclude; + }) { + if (item.type === "custom") return
{item.children}
; + + if (item.type === "menu") + return ( + + {item.url ? ( + + {item.icon} + {item.text} + + ) : ( + + {item.icon} + {item.text} + + )} + + {item.items.map((child, i) => ( + + ))} + + + ); + + return ( + + {item.text} + + ); + }; +} diff --git a/hbase-website/app/components/docs/layout/sidebar/page-tree.tsx b/hbase-website/app/components/docs/layout/sidebar/page-tree.tsx new file mode 100644 index 000000000000..f56ccfe1069b --- /dev/null +++ b/hbase-website/app/components/docs/layout/sidebar/page-tree.tsx @@ -0,0 +1,109 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { useTreeContext, useTreePath } from "fumadocs-ui/contexts/tree"; +import { type FC, type ReactNode, useMemo, Fragment } from "react"; +import type * as PageTree from "fumadocs-core/page-tree"; +import type * as Base from "./base"; + +export interface SidebarPageTreeComponents { + Item: FC<{ item: PageTree.Item }>; + Folder: FC<{ item: PageTree.Folder; children: ReactNode }>; + Separator: FC<{ item: PageTree.Separator }>; +} + +type InternalComponents = Pick< + typeof Base, + | "SidebarSeparator" + | "SidebarFolder" + | "SidebarFolderLink" + | "SidebarFolderContent" + | "SidebarFolderTrigger" + | "SidebarItem" +>; + +export function createPageTreeRenderer({ + SidebarFolder, + SidebarFolderContent, + SidebarFolderLink, + SidebarFolderTrigger, + SidebarSeparator, + SidebarItem +}: InternalComponents) { + function PageTreeFolder({ item, children }: { item: PageTree.Folder; children: ReactNode }) { + const path = useTreePath(); + + return ( + + {item.index ? ( + + {item.icon} + {item.name} + + ) : ( + + {item.icon} + {item.name} + + )} + {children} + + ); + } + + /** + * Render sidebar items from page tree + */ + return function SidebarPageTree(components: Partial) { + const { root } = useTreeContext(); + const { Separator, Item, Folder = PageTreeFolder } = components; + + return useMemo(() => { + function renderSidebarList(items: PageTree.Node[]) { + return items.map((item, i) => { + if (item.type === "separator") { + if (Separator) return ; + return ( + + {item.icon} + {item.name} + + ); + } + + if (item.type === "folder") { + return ( + + {renderSidebarList(item.children)} + + ); + } + + if (Item) return ; + return ( + + {item.name} + + ); + }); + } + + return {renderSidebarList(root.children)}; + }, [Folder, Item, Separator, root]); + }; +} diff --git a/hbase-website/app/components/docs/layout/sidebar/tabs/dropdown.tsx b/hbase-website/app/components/docs/layout/sidebar/tabs/dropdown.tsx new file mode 100644 index 000000000000..79b263ee5150 --- /dev/null +++ b/hbase-website/app/components/docs/layout/sidebar/tabs/dropdown.tsx @@ -0,0 +1,124 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { Check, ChevronsUpDown } from "lucide-react"; +import { type ComponentProps, type ReactNode, useMemo, useState } from "react"; +import Link from "fumadocs-core/link"; +import { usePathname } from "fumadocs-core/framework"; +import { isActive, normalize } from "@/lib/urls"; +import { useSidebar } from "../base"; +import { Popover, PopoverContent, PopoverTrigger } from "@/ui/popover"; +import type { SidebarTab } from "./index"; +import { cn } from "@/lib/utils"; + +export interface SidebarTabWithProps extends SidebarTab { + props?: ComponentProps<"a">; +} + +export function SidebarTabsDropdown({ + options, + placeholder, + ...props +}: { + placeholder?: ReactNode; + options: SidebarTabWithProps[]; +} & ComponentProps<"button">) { + const [open, setOpen] = useState(false); + const { closeOnRedirect } = useSidebar(); + const pathname = usePathname(); + + const selected = useMemo(() => { + return options.findLast((item) => isTabActive(item, pathname)); + }, [options, pathname]); + + const onClick = () => { + closeOnRedirect.current = false; + setOpen(false); + }; + + const item = selected ? ( + <> +
{selected.icon}
+
+

{selected.title}

+

+ {selected.description} +

+
+ + ) : ( + placeholder + ); + + return ( + + {item && ( + + {item} + + + )} + + {options.map((item) => { + const isActive = selected && item.url === selected.url; + if (!isActive && item.unlisted) return; + + return ( + +
{item.icon}
+
+

{item.title}

+

+ {item.description} +

+
+ + + + ); + })} +
+
+ ); +} + +export function isTabActive(tab: SidebarTab, pathname: string) { + if (tab.urls) return tab.urls.has(normalize(pathname)); + + return isActive(tab.url, pathname, true); +} diff --git a/hbase-website/app/components/docs/layout/sidebar/tabs/index.tsx b/hbase-website/app/components/docs/layout/sidebar/tabs/index.tsx new file mode 100644 index 000000000000..1aa5b5747623 --- /dev/null +++ b/hbase-website/app/components/docs/layout/sidebar/tabs/index.tsx @@ -0,0 +1,101 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type * as PageTree from "fumadocs-core/page-tree"; +import type { ReactNode } from "react"; + +export interface SidebarTab { + /** + * Redirect URL of the folder, usually the index page + */ + url: string; + + icon?: ReactNode; + title: ReactNode; + description?: ReactNode; + + /** + * Detect from a list of urls + */ + urls?: Set; + unlisted?: boolean; +} + +export interface GetSidebarTabsOptions { + transform?: (option: SidebarTab, node: PageTree.Folder) => SidebarTab | null; +} + +const defaultTransform: GetSidebarTabsOptions["transform"] = (option, node) => { + if (!node.icon) return option; + + return { + ...option, + icon: ( +
+ {node.icon} +
+ ) + }; +}; + +export function getSidebarTabs( + tree: PageTree.Root, + { transform = defaultTransform }: GetSidebarTabsOptions = {} +): SidebarTab[] { + const results: SidebarTab[] = []; + + function scanOptions(node: PageTree.Root | PageTree.Folder, unlisted?: boolean) { + if ("root" in node && node.root) { + const urls = getFolderUrls(node); + + if (urls.size > 0) { + const option: SidebarTab = { + url: urls.values().next().value ?? "", + title: node.name, + icon: node.icon, + unlisted, + description: node.description, + urls + }; + + const mapped = transform ? transform(option, node) : option; + if (mapped) results.push(mapped); + } + } + + for (const child of node.children) { + if (child.type === "folder") scanOptions(child, unlisted); + } + } + + scanOptions(tree); + if (tree.fallback) scanOptions(tree.fallback, true); + + return results; +} + +function getFolderUrls(folder: PageTree.Folder, output: Set = new Set()): Set { + if (folder.index) output.add(folder.index.url); + + for (const child of folder.children) { + if (child.type === "page" && !child.external) output.add(child.url); + if (child.type === "folder") getFolderUrls(child, output); + } + + return output; +} diff --git a/hbase-website/app/components/docs/layout/theme-toggle.tsx b/hbase-website/app/components/docs/layout/theme-toggle.tsx new file mode 100644 index 000000000000..5fc18c38639d --- /dev/null +++ b/hbase-website/app/components/docs/layout/theme-toggle.tsx @@ -0,0 +1,93 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { cn } from "@/lib/utils"; +import { cva } from "class-variance-authority"; +import { Airplay, Moon, Sun } from "lucide-react"; +import { useTheme } from "next-themes"; +import { type ComponentProps, useEffect, useState } from "react"; + +const itemVariants = cva("size-6.5 rounded-full p-1.5 text-fd-muted-foreground", { + variants: { + active: { + true: "bg-fd-accent text-fd-accent-foreground", + false: "text-fd-muted-foreground" + } + } +}); + +const full = [["light", Sun] as const, ["dark", Moon] as const, ["system", Airplay] as const]; + +export function ThemeToggle({ + className, + mode = "light-dark", + ...props +}: ComponentProps<"div"> & { + mode?: "light-dark" | "light-dark-system"; +}) { + const { setTheme, theme, resolvedTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const container = cn("inline-flex items-center rounded-full border p-1", className); + + if (mode === "light-dark") { + const value = mounted ? resolvedTheme : null; + + return ( + + ); + } + + const value = mounted ? theme : null; + + return ( +
+ {full.map(([key, Icon]) => ( + + ))} +
+ ); +} diff --git a/hbase-website/app/components/docs/search/create-db.ts b/hbase-website/app/components/docs/search/create-db.ts new file mode 100644 index 000000000000..108def1e5e50 --- /dev/null +++ b/hbase-website/app/components/docs/search/create-db.ts @@ -0,0 +1,110 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + create, + insertMultiple, + type Orama, + type PartialSchemaDeep, + type TypedDocument +} from "@orama/orama"; +import type { AdvancedOptions } from "fumadocs-core/search/server"; + +export type AdvancedDocument = TypedDocument>; +export const advancedSchema = { + content: "string", + page_id: "string", + type: "string", + breadcrumbs: "string[]", + tags: "enum[]", + url: "string", + embeddings: "vector[512]" +} as const; + +export async function createDB({ + indexes, + tokenizer, + ...rest +}: AdvancedOptions): Promise> { + const items = typeof indexes === "function" ? await indexes() : indexes; + + const db = create({ + schema: advancedSchema, + ...rest, + components: { + ...rest.components, + tokenizer: tokenizer ?? rest.components?.tokenizer + } + }) as Orama; + + const mapTo: PartialSchemaDeep[] = []; + items.forEach((page) => { + const pageTag = page.tag ?? []; + const tags = Array.isArray(pageTag) ? pageTag : [pageTag]; + const data = page.structuredData; + let id = 0; + + mapTo.push({ + id: page.id, + page_id: page.id, + type: "page", + content: page.title, + breadcrumbs: page.breadcrumbs, + tags, + url: page.url + }); + + const nextId = () => `${page.id}-${id++}`; + + if (page.description) { + mapTo.push({ + id: nextId(), + page_id: page.id, + tags, + type: "text", + url: page.url, + content: page.description + }); + } + + for (const heading of data.headings) { + mapTo.push({ + id: nextId(), + page_id: page.id, + type: "heading", + tags, + url: `${page.url}#${heading.id}`, + content: heading.content + }); + } + + for (const content of data.contents) { + mapTo.push({ + id: nextId(), + page_id: page.id, + tags, + type: "text", + url: content.heading ? `${page.url}#${content.heading}` : page.url, + content: content.content + }); + } + }); + + await insertMultiple(db, mapTo); + return db; +} diff --git a/hbase-website/app/components/docs/search/create-from-source.ts b/hbase-website/app/components/docs/search/create-from-source.ts new file mode 100644 index 000000000000..5ef4673d4ae5 --- /dev/null +++ b/hbase-website/app/components/docs/search/create-from-source.ts @@ -0,0 +1,136 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + type AdvancedIndex, + type AdvancedOptions, + createI18nSearchAPI, + type SearchAPI, + createSearchAPI +} from "fumadocs-core/search/server"; +import { PathUtils } from "fumadocs-core/source"; +import type { Language } from "@orama/orama"; +import type { LoaderConfig, LoaderOutput, Page } from "fumadocs-core/source"; +import type { I18nConfig } from "fumadocs-core/i18n"; +import { findPath } from "fumadocs-core/page-tree"; +import type { StructuredData } from "fumadocs-core/mdx-plugins"; + +type Awaitable = T | Promise; + +function defaultBuildIndex( + source: LoaderOutput, + tag?: (pageUrl: string) => string +) { + function isBreadcrumbItem(item: unknown): item is string { + return typeof item === "string" && item.length > 0; + } + + return async (page: Page): Promise => { + let breadcrumbs: string[] | undefined; + let structuredData: StructuredData | undefined; + + if ("structuredData" in page.data) { + structuredData = page.data.structuredData as StructuredData; + } else if ("load" in page.data && typeof page.data.load === "function") { + structuredData = (await page.data.load()).structuredData; + } + + if (!structuredData) + throw new Error( + "Cannot find structured data from page, please define the page to index function." + ); + + const pageTree = source.getPageTree(page.locale); + const path = findPath( + pageTree.children, + (node) => node.type === "page" && node.url === page.url + ); + if (path) { + breadcrumbs = []; + path.pop(); + + if (isBreadcrumbItem(pageTree.name)) { + breadcrumbs.push(pageTree.name); + } + + for (const segment of path) { + if (!isBreadcrumbItem(segment.name)) continue; + + breadcrumbs.push(segment.name); + } + } + + return { + title: page.data.title ?? PathUtils.basename(page.path, PathUtils.extname(page.path)), + breadcrumbs, + description: page.data.description, + url: page.url, + id: page.url, + structuredData, + tag: tag?.(page.url) + }; + }; +} + +interface Options extends Omit { + localeMap?: { + [K in C["i18n"] extends I18nConfig ? Languages : string]?: + | Partial + | Language; + }; + buildIndex?: (page: Page) => Awaitable; + tag?: (pageUrl: string) => string; +} + +export function createFromSource( + source: LoaderOutput, + options?: Options +): SearchAPI; + +export function createFromSource( + source: LoaderOutput, + options: Options = {} +): SearchAPI { + const { buildIndex = defaultBuildIndex(source, options.tag) } = options; + + if (source._i18n) { + return createI18nSearchAPI("advanced", { + ...options, + i18n: source._i18n, + indexes: async () => { + const indexes = source.getLanguages().flatMap((entry) => { + return entry.pages.map(async (page) => ({ + ...(await buildIndex(page)), + locale: entry.language + })); + }); + + return Promise.all(indexes); + } + }); + } + + return createSearchAPI("advanced", { + ...options, + indexes: async () => { + const indexes = source.getPages().map((page) => buildIndex(page)); + + return Promise.all(indexes); + } + }); +} diff --git a/hbase-website/app/components/docs/search/docs-search.tsx b/hbase-website/app/components/docs/search/docs-search.tsx new file mode 100644 index 000000000000..fc6142217a88 --- /dev/null +++ b/hbase-website/app/components/docs/search/docs-search.tsx @@ -0,0 +1,78 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + SearchDialog as FumaDocsSearchDialog, + SearchDialogClose, + SearchDialogContent, + SearchDialogHeader, + SearchDialogIcon, + SearchDialogInput, + SearchDialogList, + SearchDialogOverlay, + type SharedProps +} from "fumadocs-ui/components/dialog/search"; +import { useDocsSearch } from "./use-docs-search"; +import { create } from "@orama/orama"; +import { useI18n } from "fumadocs-ui/contexts/i18n"; + +function initOrama() { + return create({ + schema: { _: "string" }, + language: "english" + }); +} + +export function SearchDialog(props: SharedProps) { + const { locale } = useI18n(); + + const { search, setSearch, query } = useDocsSearch({ + type: "static", + initOrama, + locale, + tag: "multi-page" + }); + + return ( + + + + + + + + + ({ + ...i, + breadcrumbs: i.breadcrumbs?.filter((k) => k !== "Multi-Page Documentation") + })) + : null + } + /> + + + ); +} diff --git a/hbase-website/app/components/docs/search/static.ts b/hbase-website/app/components/docs/search/static.ts new file mode 100644 index 000000000000..5be018e12b92 --- /dev/null +++ b/hbase-website/app/components/docs/search/static.ts @@ -0,0 +1,324 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + type AnyOrama, + create, + load, + type Orama, + type SearchParams, + search as searchOrama, + getByID +} from "@orama/orama"; +import { type AdvancedDocument, type advancedSchema } from "./create-db"; +import { createContentHighlighter, type SortedResult } from "fumadocs-core/search"; +import type { ExportedData } from "fumadocs-core/search/server"; +import { removeUndefined } from "./utils"; + +export interface StaticOptions { + /** + * Where to download exported search indexes (URL) + * + * @defaultValue '/api/search' + */ + from?: string; + + initOrama?: (locale?: string) => AnyOrama | Promise; + + /** + * Filter results with specific tag(s). + */ + tag?: string | string[]; + + /** + * Filter by locale (unsupported at the moment) + */ + locale?: string; +} + +const cache = new Map>(); + +// locale -> db +type Database = Map< + string, + { + db: AnyOrama; + } +>; + +async function loadDB({ + from = "/api/search", + initOrama = (locale) => create({ schema: { _: "string" }, language: locale }) +}: StaticOptions): Promise { + const cacheKey = from; + const cached = cache.get(cacheKey); + if (cached) return cached; + + async function init() { + const res = await fetch(from); + + if (!res.ok) + throw new Error( + `failed to fetch exported search indexes from ${from}, make sure the search database is exported and available for client.` + ); + + const data = (await res.json()) as ExportedData; + const dbs: Database = new Map(); + + if (data.type === "i18n") { + await Promise.all( + Object.entries(data.data).map(async ([k, v]) => { + const db = await initOrama(k); + + load(db, v); + dbs.set(k, { + db + }); + }) + ); + + return dbs; + } + + const db = await initOrama(); + load(db, data); + dbs.set("", { + db + }); + return dbs; + } + + const result = init(); + cache.set(cacheKey, result); + return result; +} + +export async function search(query: string, options: StaticOptions) { + const { tag, locale } = options; + + const db = (await loadDB(options)).get(locale ?? ""); + + if (!db) return []; + + return searchAdvanced(db.db as Orama, query, tag); +} + +export async function searchAdvanced( + db: Orama, + query: string, + tag: string | string[] = [], + { + mode = "fulltext", + ...override + }: Partial, AdvancedDocument>> = {} +): Promise { + if (typeof tag === "string") tag = [tag]; + + let params = { + ...override, + mode, + where: removeUndefined({ + tags: + tag.length > 0 + ? { + containsAll: tag + } + : undefined, + ...override.where + }), + groupBy: { + properties: ["page_id"], + maxResult: 8, + ...override.groupBy + } + } as SearchParams; + + if (query.length > 0) { + params = { + ...params, + term: query, + properties: mode === "fulltext" ? ["content"] : ["content", "embeddings"] + } as SearchParams; + } + + const highlighter = createContentHighlighter(query); + const result = await searchOrama(db, params); + + // Helper to detect phrase matches (3+ consecutive words) + const getPhraseMatchBoost = (content: string, searchTerm: string): number => { + const contentLower = content.toLowerCase(); + const termLower = searchTerm.toLowerCase(); + + // Split search term into words + const searchWords = termLower.split(/\s+/).filter((w) => w.length > 0); + + // Need at least 3 words for phrase matching + if (searchWords.length < 3) return 0; + + // Check for longest consecutive word match + let maxConsecutiveMatch = 0; + + for (let i = 0; i <= searchWords.length - 3; i++) { + // Try matching from 3 words up to all remaining words + for (let len = 3; len <= searchWords.length - i; len++) { + const phrase = searchWords.slice(i, i + len).join(" "); + if (contentLower.includes(phrase)) { + maxConsecutiveMatch = Math.max(maxConsecutiveMatch, len); + } + } + } + + // Boost based on length of consecutive match + // Make this VERY high to dominate over heading matches + // 3 words: +10000, 4 words: +15000, 5+ words: +20000+ + if (maxConsecutiveMatch >= 3) { + return 10000 + (maxConsecutiveMatch - 3) * 5000; + } + + return 0; + }; + + // Helper to score match quality (exact > starts with > contains) + const getMatchQuality = (content: string, searchTerm: string): number => { + const lower = content.toLowerCase(); + const term = searchTerm.toLowerCase(); + + if (lower === term) return 1000; // Exact match + if (lower.startsWith(term + " ")) return 500; // Starts with term + space + if (lower.startsWith(term)) return 400; // Starts with term + if (new RegExp(`\\b${term}\\b`, "i").test(content)) return 300; // Whole word + if (lower.includes(term)) return 100; // Contains + return 0; + }; + + // Collect all groups with scoring + const groupsWithScores: Array<{ + pageId: string; + pageScore: number; + matchQuality: number; + phraseBoost: number; + totalScore: number; + page: any; + hits: any[]; + }> = []; + + for (const item of result.groups ?? []) { + const pageId = item.values[0] as string; + const page = getByID(db, pageId); + if (!page) continue; + + // Find the page hit to get its Orama score + const pageHit = item.result.find((hit: any) => hit.document.type === "page"); + const pageScore = pageHit?.score || 0; + + // Check for phrase matches in ALL hits (page title + all content sections) + // Use the BEST phrase match from any hit to boost the entire group + let bestPhraseBoost = 0; + let bestMatchQuality = 0; + + for (const hit of item.result) { + const hitPhraseBoost = getPhraseMatchBoost(hit.document.content, query); + const hitMatchQuality = getMatchQuality(hit.document.content, query); + + if (hitPhraseBoost > bestPhraseBoost) { + bestPhraseBoost = hitPhraseBoost; + } + if (hitMatchQuality > bestMatchQuality) { + bestMatchQuality = hitMatchQuality; + } + } + + const totalScore = bestMatchQuality + bestPhraseBoost; + + groupsWithScores.push({ + pageId, + pageScore, + matchQuality: bestMatchQuality, + phraseBoost: bestPhraseBoost, + totalScore, + page, + hits: item.result + }); + } + + // Sort groups: phrase matches + exact matches first, then by Orama score + groupsWithScores.sort((a, b) => { + // Prioritize results with phrase matches and exact matches + if (a.totalScore !== b.totalScore) { + return b.totalScore - a.totalScore; + } + // Then by Orama relevance + return b.pageScore - a.pageScore; + }); + + const list: SortedResult[] = []; + + // Build final list from sorted groups + for (const { pageId, page, hits } of groupsWithScores) { + // Add page title + list.push({ + id: pageId, + type: "page", + content: page.content, + breadcrumbs: page.breadcrumbs, + contentWithHighlights: highlighter.highlight(page.content), + url: page.url + }); + + // Sort hits within this group: by phrase match + match quality, then type, then Orama score + const sortedHits = [...hits] + .filter((hit: any) => hit.document.type !== "page") + .map((hit: any) => { + const typeScore = hit.document.type === "heading" ? 2 : 1; + const matchQuality = getMatchQuality(hit.document.content, query); + const phraseBoost = getPhraseMatchBoost(hit.document.content, query); + const totalScore = matchQuality + phraseBoost; + + return { + hit, + typeScore, + matchQuality, + phraseBoost, + totalScore + }; + }) + .sort((a, b) => { + // First prioritize phrase matches and exact matches (combined score) + if (a.totalScore !== b.totalScore) return b.totalScore - a.totalScore; + // Then by type (heading > text) + if (a.typeScore !== b.typeScore) return b.typeScore - a.typeScore; + // Then by Orama relevance + return b.hit.score - a.hit.score; + }) + .map((item) => item.hit); + + // Add sorted hits + for (const hit of sortedHits) { + list.push({ + id: hit.document.id.toString(), + content: hit.document.content, + breadcrumbs: hit.document.breadcrumbs, + contentWithHighlights: highlighter.highlight(hit.document.content), + type: hit.document.type as SortedResult["type"], + url: hit.document.url + }); + } + } + + return list; +} diff --git a/hbase-website/app/components/docs/search/use-docs-search.ts b/hbase-website/app/components/docs/search/use-docs-search.ts new file mode 100644 index 000000000000..9b85048a7810 --- /dev/null +++ b/hbase-website/app/components/docs/search/use-docs-search.ts @@ -0,0 +1,137 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { type DependencyList, useRef, useState } from "react"; +import { type StaticOptions } from "fumadocs-core/search/client"; +import type { SortedResult } from "fumadocs-core/search"; +import { useDebounce, useOnChange } from "./utils"; + +interface UseDocsSearch { + search: string; + setSearch: (v: string) => void; + query: { + isLoading: boolean; + data?: SortedResult[] | "empty"; + error?: Error; + }; +} + +export type Client = { + type: "static"; +} & StaticOptions; + +function isDeepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + + if (Array.isArray(a) && Array.isArray(b)) { + return b.length === a.length && a.every((v, i) => isDeepEqual(v, b[i])); + } + + if (typeof a === "object" && a && typeof b === "object" && b) { + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + + return ( + aKeys.length === bKeys.length && + aKeys.every( + (key) => + Object.hasOwn(b, key) && isDeepEqual(a[key as keyof object], b[key as keyof object]) + ) + ); + } + + return false; +} + +/** + * Provide a hook to query different official search clients. + * + * Note: it will re-query when its parameters changed, make sure to use `useMemo()` on `clientOptions` or define `deps` array. + */ +export function useDocsSearch( + clientOptions: Client & { + /** + * The debounced delay for performing a search (in ms). + * . + * @defaultValue 100 + */ + delayMs?: number; + + /** + * still perform search even if query is empty. + * + * @defaultValue false + */ + allowEmpty?: boolean; + }, + deps?: DependencyList +): UseDocsSearch { + const { delayMs = 100, allowEmpty = false, ...client } = clientOptions; + + const [search, setSearch] = useState(""); + const [results, setResults] = useState("empty"); + const [error, setError] = useState(); + const [isLoading, setIsLoading] = useState(false); + const debouncedValue = useDebounce(search, delayMs); + const onStart = useRef<() => void>(undefined); + + useOnChange( + [deps ?? clientOptions, debouncedValue], + () => { + if (onStart.current) { + onStart.current(); + onStart.current = undefined; + } + + setIsLoading(true); + let interrupt = false; + onStart.current = () => { + interrupt = true; + }; + + async function run(): Promise { + if (debouncedValue.length === 0 && !allowEmpty) return "empty"; + switch (client.type) { + case "static": { + const { search } = await import("./static"); + return search(debouncedValue, client); + } + default: + throw new Error("unknown search client"); + } + } + + void run() + .then((res) => { + if (interrupt) return; + + setError(undefined); + setResults(res); + }) + .catch((err: Error) => { + setError(err); + }) + .finally(() => { + setIsLoading(false); + }); + }, + deps ? undefined : (a, b) => !isDeepEqual(a, b) + ); + + return { search, setSearch, query: { isLoading, data: results, error } }; +} diff --git a/hbase-website/app/components/docs/search/utils.ts b/hbase-website/app/components/docs/search/utils.ts new file mode 100644 index 000000000000..04b58029c6e7 --- /dev/null +++ b/hbase-website/app/components/docs/search/utils.ts @@ -0,0 +1,83 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { useEffect, useState } from "react"; + +export function removeUndefined(value: T, deep = false): T { + const obj = value as Record; + + for (const key in obj) { + if (obj[key] === undefined) delete obj[key]; + if (!deep) continue; + + const entry = obj[key]; + + if (typeof entry === "object" && entry !== null) { + removeUndefined(entry, deep); + continue; + } + + if (Array.isArray(entry)) { + for (const item of entry) removeUndefined(item, deep); + } + } + + return value; +} + +/** + * @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); + } +} + +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; +} + +export function useDebounce(value: T, delayMs = 1000): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + if (delayMs === 0) return; + const handler = window.setTimeout(() => { + setDebouncedValue(value); + }, delayMs); + + return () => clearTimeout(handler); + }, [delayMs, value]); + + if (delayMs === 0) return value; + return debouncedValue; +} diff --git a/hbase-website/app/components/docs/toc/clerk.tsx b/hbase-website/app/components/docs/toc/clerk.tsx new file mode 100644 index 000000000000..4a8744e13562 --- /dev/null +++ b/hbase-website/app/components/docs/toc/clerk.tsx @@ -0,0 +1,182 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import * as Primitive from "fumadocs-core/toc"; +import { type ComponentProps, useEffect, useRef, useState } from "react"; +import { TocThumb, useTOCItems } from "./index"; +import { useI18n } from "fumadocs-ui/contexts/i18n"; +import { cn, mergeRefs } from "@/lib/utils"; + +export function TOCItems({ ref, className, ...props }: ComponentProps<"div">) { + const containerRef = useRef(null); + const items = useTOCItems(); + const { text } = useI18n(); + + const [svg, setSvg] = useState<{ + path: string; + width: number; + height: number; + }>(); + + useEffect(() => { + if (!containerRef.current) return; + const container = containerRef.current; + + function onResize(): void { + if (container.clientHeight === 0) return; + let w = 0, + h = 0; + const d: string[] = []; + for (let i = 0; i < items.length; i++) { + const element: HTMLElement | null = container.querySelector( + `a[href="#${items[i].url.slice(1)}"]` + ); + if (!element) continue; + + const styles = getComputedStyle(element); + const offset = getLineOffset(items[i].depth) + 1, + top = element.offsetTop + parseFloat(styles.paddingTop), + bottom = element.offsetTop + element.clientHeight - parseFloat(styles.paddingBottom); + + w = Math.max(offset, w); + h = Math.max(h, bottom); + + d.push(`${i === 0 ? "M" : "L"}${offset} ${top}`); + d.push(`L${offset} ${bottom}`); + } + + setSvg({ + path: d.join(" "), + width: w + 1, + height: h + }); + } + + const observer = new ResizeObserver(onResize); + onResize(); + + observer.observe(container); + return () => { + observer.disconnect(); + }; + }, [items]); + + if (items.length === 0) + return ( +
+ {text.tocNoHeadings} +
+ ); + + return ( + <> + {svg && ( +
` + ) + }")` + }} + > + +
+ )} +
+ {items.map((item, i) => ( + + ))} +
+ + ); +} + +function getItemOffset(depth: number): number { + if (depth <= 2) return 14; + if (depth === 3) return 26; + return 36; +} + +function getLineOffset(depth: number): number { + return depth >= 3 ? 10 : 0; +} + +function TOCItem({ + item, + upper = item.depth, + lower = item.depth +}: { + item: Primitive.TOCItemType; + upper?: number; + lower?: number; +}) { + const offset = getLineOffset(item.depth), + upperOffset = getLineOffset(upper), + lowerOffset = getLineOffset(lower); + + return ( + + {offset !== upperOffset && ( + + + + )} +
+ {item.title} + + ); +} diff --git a/hbase-website/app/components/docs/toc/default.tsx b/hbase-website/app/components/docs/toc/default.tsx new file mode 100644 index 000000000000..a4630c0bb0f4 --- /dev/null +++ b/hbase-website/app/components/docs/toc/default.tsx @@ -0,0 +1,70 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { useI18n } from "fumadocs-ui/contexts/i18n"; +import { type ComponentProps, useRef } from "react"; +import { TocThumb, useTOCItems } from "./index"; +import * as Primitive from "fumadocs-core/toc"; +import { cn, mergeRefs } from "@/lib/utils"; + +export function TOCItems({ ref, className, ...props }: ComponentProps<"div">) { + const containerRef = useRef(null); + const items = useTOCItems(); + const { text } = useI18n(); + + if (items.length === 0) + return ( +
+ {text.tocNoHeadings} +
+ ); + + return ( + <> + +
+ {items.map((item) => ( + + ))} +
+ + ); +} + +function TOCItem({ item }: { item: Primitive.TOCItemType }) { + return ( + = 4 && "ps-8" + )} + > + {item.title} + + ); +} diff --git a/hbase-website/app/components/docs/toc/index.tsx b/hbase-website/app/components/docs/toc/index.tsx new file mode 100644 index 000000000000..238c086e09b8 --- /dev/null +++ b/hbase-website/app/components/docs/toc/index.tsx @@ -0,0 +1,133 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import * as Primitive from "fumadocs-core/toc"; +import { + type ComponentProps, + createContext, + type RefObject, + use, + useEffect, + useEffectEvent, + useRef +} from "react"; +import { useOnChange } from "fumadocs-core/utils/use-on-change"; +import { cn, mergeRefs } from "@/lib/utils"; + +const TOCContext = createContext([]); + +export function useTOCItems(): Primitive.TOCItemType[] { + return use(TOCContext); +} + +export function TOCProvider({ + toc, + children, + ...props +}: ComponentProps) { + return ( + + + {children} + + + ); +} + +export function TOCScrollArea({ ref, className, ...props }: ComponentProps<"div">) { + const viewRef = useRef(null); + + return ( +
+ {props.children} +
+ ); +} + +type TocThumbType = [top: number, height: number]; + +interface RefProps { + containerRef: RefObject; +} + +export function TocThumb({ containerRef, ...props }: ComponentProps<"div"> & RefProps) { + const thumbRef = useRef(null); + const active = Primitive.useActiveAnchors(); + function update(info: TocThumbType): void { + const element = thumbRef.current; + if (!element) return; + element.style.setProperty("--fd-top", `${info[0]}px`); + element.style.setProperty("--fd-height", `${info[1]}px`); + } + + const onPrint = useEffectEvent(() => { + if (containerRef.current) { + update(calc(containerRef.current, active)); + } + }); + + useEffect(() => { + if (!containerRef.current) return; + const container = containerRef.current; + + const observer = new ResizeObserver(onPrint); + observer.observe(container); + + return () => { + observer.disconnect(); + }; + }, [containerRef, onPrint]); + + useOnChange(active, () => { + if (containerRef.current) { + update(calc(containerRef.current, active)); + } + }); + + return
; +} + +function calc(container: HTMLElement, active: string[]): TocThumbType { + 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]; +} diff --git a/hbase-website/app/components/links.ts b/hbase-website/app/components/links.ts index c4fe3d008195..99db6310e9f3 100644 --- a/hbase-website/app/components/links.ts +++ b/hbase-website/app/components/links.ts @@ -69,7 +69,7 @@ export const projectLinks: LinkType[] = [ export const documentationLinks: (LinkType | NestedLinkType)[] = [ { label: "Reference Guide", - to: "https://hbase.apache.org/book.html" + to: "/docs" }, { label: "Reference Guide (PDF)", diff --git a/hbase-website/app/components/markdown-layout.tsx b/hbase-website/app/components/markdown-layout.tsx index f34886cc5533..3a99cb9bee4e 100644 --- a/hbase-website/app/components/markdown-layout.tsx +++ b/hbase-website/app/components/markdown-layout.tsx @@ -32,6 +32,7 @@ interface MarkdownLayoutProps { components?: Components; } +// Deprecated export function MarkdownLayout({ children, autoLinkHeadings = false, diff --git a/hbase-website/app/components/mdx-components.tsx b/hbase-website/app/components/mdx-components.tsx new file mode 100644 index 000000000000..c9c9714c2707 --- /dev/null +++ b/hbase-website/app/components/mdx-components.tsx @@ -0,0 +1,122 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { MDXComponents } from "mdx/types"; +import { ExternalLinkIcon } from "lucide-react"; +import defaultMdxComponents from "fumadocs-ui/mdx"; +import { cn } from "@/lib/utils"; +import { Link } from "./link"; + +export function getMDXComponents(overrides?: MDXComponents): MDXComponents { + return { + ...defaultMdxComponents, + h1: (props) => ( +

+ ), + h2: (props) => ( +

+ ), + h3: (props) => ( +

+ ), + p: (props) =>

, + ol: (props) =>

    , + ul: (props) =>