diff --git a/hbase-website/.gitignore b/hbase-website/.gitignore index 2888d6734ed0..e4d11a60b6ea 100644 --- a/hbase-website/.gitignore +++ b/hbase-website/.gitignore @@ -18,6 +18,9 @@ /build/ .vite +# Fumadocs +.source/ + # Logs logs *.log @@ -33,4 +36,14 @@ lerna-debug.log* # Generated files /app/pages/team/developers.json +/app/pages/_docs/docs/_mdx/(multi-page)/configuration/hbase-default.md +/app/lib/export-pdf/hbase-version.json +/public/books/** +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ 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/README.md b/hbase-website/README.md index 228f6607271d..b568a0e3351c 100644 --- a/hbase-website/README.md +++ b/hbase-website/README.md @@ -40,14 +40,16 @@ The official website for Apache HBase, built with modern web technologies to pro ## Content Editing -Most pages (except the home page) store content in **Markdown (`.md`)** or **JSON (`.json`)** files located in `app/pages/[page-name]/`. This makes it easy to update content without touching code. +Most landing pages store content in **Markdown (`.md`)** or **JSON (`.json`)** files located in `app/pages/_landing/[page-name]/`. Docs content lives under `app/pages/_docs/` and is authored in MDX. + +Legacy documentation is preserved for those users who have old bookmarked links and notes: the old book lives at `/public/book.html`, and its static assets are in `public/old-book-static-files/`. **Examples:** -- `app/pages/team/content.md` - Markdown content for team page -- `app/pages/powered-by-hbase/companies.json` - JSON data for companies -- `app/pages/news/events.json` - JSON data for news/events -Edit these files with any text editor, then run `npm run build` to regenerate the site. +- `app/pages/_landing/team/content.md` - Markdown content for team page +- `app/pages/_landing/powered-by-hbase/companies.json` - JSON data for companies +- `app/pages/_landing/news/events.json` - JSON data for news/events +- `app/pages/_docs/docs/_mdx/(multi-page)/...` - MDX content for documentation --- @@ -70,12 +72,21 @@ Before you begin, ensure you have the following installed: This website uses modern web technologies. Here's what each one does (with Java analogies): #### Core Framework + - **React Router** - Full-stack web framework with Server-Side Generation (SSG) - Handles routing (like Spring MVC controllers) - Provides server-side rendering for better performance and SEO - Enables progressive enhancement (see below) - [Documentation](https://reactrouter.com/) +#### Documentation Framework + +- **Fumadocs** - Documentation framework used for the docs section + - Provides MDX-based docs structure and navigation + - Lives alongside the landing pages in the same React Router app + - Supports multi-page and single-page docs from the same MDX sources + - [Documentation](https://fumadocs.com/) + #### Progressive Enhancement The website uses **progressive enhancement** ([learn more](https://reactrouter.com/explanation/progressive-enhancement)), which means: @@ -93,29 +104,26 @@ The website uses **progressive enhancement** ([learn more](https://reactrouter.c This approach ensures the website works for all users, regardless of their browser capabilities or connection speed. #### UI Components + - **shadcn/ui** - Pre-built, accessible UI components - - Built on top of Radix UI primitives - Similar to a component library like PrimeFaces or Vaadin in Java - Provides buttons, cards, navigation menus, etc. - [Documentation](https://ui.shadcn.com/) -- **Radix UI** - Low-level, accessible UI primitives - - The foundation that shadcn/ui builds upon - - Handles complex accessibility (ARIA) requirements automatically - - Think of it as the "Spring Framework" for UI components - #### Styling -- **TailwindCSS** - Utility-first CSS framework + +- **TailwindCSS** - Utility-first CSS framework, aka Bootstrap on steroids - Instead of writing CSS files, you apply classes directly in components - Example: `className="text-blue-500 font-bold"` makes blue, bold text #### Code Quality Tools + - **TypeScript** - Typed superset of JavaScript - Similar to Java's type system - Catches errors at compile-time instead of runtime - Provides autocomplete and better IDE support -- **ESLint + Prettier** - Code linting and formatting (like Checkstyle + google-java-format) +- **ESLint + Prettier** - Code linting and formatting (like Checkstyle) - ESLint analyzes code for potential errors and enforces coding standards - Prettier handles automatic code formatting (spacing, indentation, etc.) - Integrated together: `npm run lint:fix` handles both linting and formatting @@ -127,47 +135,55 @@ The project follows a clear directory structure with separation of concerns: ``` my-react-router-app/ -├── app/ # Application source code -│ ├── ui/ # Reusable UI components (no business logic) -│ │ ├── button.tsx # Generic button component -│ │ ├── card.tsx # Card container component -│ │ └── ... # Other UI primitives +├── app/ # Application source code +│ ├── ui/ # Reusable UI components (no business logic) +│ │ ├── button.tsx # Generic button component +│ │ ├── card.tsx # Card container component +│ │ └── ... # Other UI primitives │ │ -│ ├── components/ # Reusable components WITH business logic -│ │ ├── site-navbar.tsx # Website navigation bar -│ │ ├── site-footer.tsx # Website footer -│ │ ├── theme-toggle.tsx # Dark/light mode toggle -│ │ └── markdown-layout.tsx # Layout for markdown content pages +│ ├── components/ # Reusable components WITH business logic +│ │ ├── site-navbar.tsx # Website navigation bar +│ │ ├── site-footer.tsx # Website footer +│ │ ├── theme-toggle.tsx # Dark/light mode toggle +│ │ └── markdown-layout.tsx # Layout for markdown content pages │ │ -│ ├── pages/ # Complete pages (composed of ui + components) -│ │ ├── home/ # Home page -│ │ │ ├── index.tsx # Main page component (exported) -│ │ │ ├── hero.tsx # Hero section (not exported) -│ │ │ ├── features.tsx # Features section (not exported) +│ ├── pages/ # Complete pages (composed of ui + components) +│ │ ├── _landing/ # Landing pages + layout +│ │ │ ├── home/ # Home page +│ │ │ │ ├── index.tsx # Main page component (exported) +│ │ │ │ ├── hero.tsx # Hero section (not exported) +│ │ │ │ ├── features.tsx # Features section (not exported) +│ │ │ │ └── ... +│ │ │ ├── team/ # Landing page content +│ │ │ └── ... +│ │ ├── _docs/ # Documentation (Fumadocs) +│ │ │ ├── docs/ # MDX content and structure +│ │ │ ├── docs-layout.tsx # Fumadocs layout wrapper │ │ │ └── ... -│ │ ├── downloads/ # Downloads page -│ │ │ ├── index.tsx # Main page component (exported) -│ │ │ └── content.md # Markdown content -│ │ └── ... │ │ -│ ├── routes/ # Route definitions and metadata -│ │ ├── home.tsx # Home route configuration -│ │ ├── download.tsx # Downloads route configuration +│ ├── routes/ # Route definitions and metadata +│ │ ├── home.tsx # Home route configuration +│ │ ├── team.tsx # Team route configuration │ │ └── ... │ │ -│ ├── lib/ # Utility functions and integrations -│ │ ├── utils.ts # Helper functions -│ │ └── theme-provider.tsx # Theme management +│ ├── lib/ # Utility functions and integrations +│ │ ├── utils.ts # Helper functions +│ │ └── theme-provider.tsx # Theme management │ │ -│ ├── routes.ts # Main routing configuration -│ ├── root.tsx # Root layout component -│ └── app.css # Global styles +│ ├── routes.ts # Main routing configuration +│ ├── root.tsx # Root layout component +│ └── app.css # Global styles │ ├── build/ # Generated files (DO NOT EDIT) +│ ├── client/ # Browser-side assets +│ │ ├── index.html # HTML files for each page +│ │ ├── assets/ # JavaScript, CSS bundles +│ │ └── images/ # Optimized images │ ├── public/ # Static files (copied as-is to build/) │ ├── favicon.ico # Website icon -│ └── images/ # Images and other static assets +│ ├── images/ # Image assets +| └── ... │ ├── node_modules/ # Dependencies (like Maven's .m2 directory) ├── package.json # Project metadata and dependencies (like pom.xml) @@ -195,6 +211,15 @@ my-react-router-app/ - Maps URLs to pages - Sets page titles, meta tags, etc. +5. **Two Layout Systems in One App**: + - **Landing pages** live under `app/pages/_landing/` and use the landing layout. + - **Docs pages** live under `app/pages/_docs/` and use Fumadocs layouts. + - Both are part of the same React Router application, but render with different layouts and visual styles. + +6. **Documentation Versions**: + - **Multi-page docs** live under `app/pages/_docs/docs/_mdx/(multi-page)/` and are the source of truth. + - **Single-page docs** live under `app/pages/_docs/docs/_mdx/single-page/` and import content from the multi-page docs. + #### Important Conventions ##### Custom Link Component @@ -237,7 +262,7 @@ npm install This downloads all required packages from npm (similar to Maven Central). -#### 2. Generate Developers Data +#### 2. Generate Developers and Config Data **Important:** Before starting the development server, generate the `developers.json` file from the root `pom.xml`: @@ -245,7 +270,23 @@ This downloads all required packages from npm (similar to Maven Central). npm run extract-developers ``` -This extracts the developer information from the parent `pom.xml` file and creates `app/pages/team/developers.json`, which is required for the Team page to work properly. Re-run this command whenever the developers section in `pom.xml` changes. +This extracts the developer information from the parent `pom.xml` file and creates `app/pages/team/developers.json`, which is required for the Team page to work properly. Re-run this command whenever the developers section in `pom.xml` changes. The output json is ignored by git, and this command also runs at a build time, so there is no need to `git commit` the generated file. + +**Important:** Generate the HBase configuration markdown before starting the development server: + +```bash +npm run extract-hbase-config +``` + +This extracts data from `hbase-default.xml` and creates `app/pages/_docs/docs/_mdx/(multi-page)/configuration/hbase-default.md`, which is required for the documentation page to work properly. Re-run this command whenever `hbase-default.xml` changes. The generated markdown is ignored by git, and this command also runs at build time, so there is no need to `git commit` the generated file. + +**Important:** Generate the HBase version metadata before starting the development server: + +```bash +npm run extract-hbase-version +``` + +This extracts the `` value from the root `pom.xml` and creates `app/lib/export-pdf/hbase-version.json`, which is used on the docs PDF cover. Re-run this command whenever the root `pom.xml` version changes. The generated json is ignored by git, and this command also runs at build time, so there is no need to `git commit` the generated file. #### 3. Start Development Server @@ -254,6 +295,7 @@ npm run dev ``` This starts a local development server with: + - **Hot Module Replacement (HMR)**: Code changes appear instantly without full page reload - **Live at**: `http://localhost:5173` @@ -263,55 +305,81 @@ This starts a local development server with: 1. **Edit code** in the `app/` directory 2. **Save the file** - changes appear automatically in the browser -3. **Check for errors** in the terminal where `npm run dev` is running +3. **Check for errors** in the terminal where `npm run dev` is running and in browser console #### Common Tasks **Add a new page:** + 1. Create directory in `app/pages/my-new-page/` 2. Create `index.tsx` in that directory 3. Create route file in `app/routes/my-new-page.tsx` 4. Register route in `app/routes.ts` +**Add a new documentation page:** + +1. Create a new `.mdx` file in `app/pages/_docs/docs/_mdx/(multi-page)/` (for example `my-topic.mdx`). +2. Add the new file to the relevant `meta.json` in the same section folder so it appears in navigation. +3. Import the page into `app/pages/_docs/docs/_mdx/single-page/index.mdx` and add an `#` header so it renders in the single-page docs. + **Update content:** + - Edit the appropriate `.md` or `.json` file - Changes appear automatically **Add a UI component:** + - Check if shadcn/ui has what you need first - Only create custom components if necessary **Check code quality:** + ```bash npm run lint ``` **Fix linting and formatting issues:** + ```bash npm run lint:fix ``` ### Testing -The project uses [Vitest](https://vitest.dev/) for testing React components. +The project uses [Vitest](https://vitest.dev/) and [Playwright](http://playwright.dev/) for testing. Vitest is for unit testing, while Playwright is for e2e testing. + +#### Export Documentation PDF + +The docs PDF export is implemented as a Playwright e2e test in `e2e-tests/export-pdf.spec.ts`. It runs during `npm run ci` and generates static PDF assets for the documentation by rendering the single-page docs in both light and dark themes (HTML → PDF). + +The export quality depends heavily on the `@media print` styles defined in `app/app.css`, which control layout, pagination, and print-only behavior. + +There is also a dedicated command you can run manually when needed: + +```bash +npm run export-pdf +``` + +This command is not part of the CI pipeline and does not run automatically unless invoked directly. **Run tests:** + ```bash -# Run tests in watch mode (for development) +# Run all tests npm test -# Run tests once (for CI/CD) +# Run unit tests once (for CI/CD) npm run test:run -# Run tests with UI +# Run unit tests with UI npm run test:ui -``` -**Test coverage includes:** -- Home Page - Hero section, buttons, features, use cases, community sections -- Theme Toggle - Light/dark mode switching -- Navigation - Navbar, dropdown menus, links -- Markdown Rendering - Headings, lists, code blocks, tables, links +# Run e2e tests +npm run test:e2e + +# Run e2e tests with UI +npm run test:e2e:ui +``` **Writing new tests:** @@ -329,6 +397,8 @@ describe('MyComponent', () => { }) ``` +### Building for Production + **CI/CD Workflow:** Before merging or deploying, run the full CI pipeline: @@ -337,49 +407,9 @@ Before merging or deploying, run the full CI pipeline: npm run ci ``` -This command runs all quality checks and builds the project: -1. `npm run lint` - Check linting -2. `npm run typecheck` - Check types -3. `npm run test:run` - Run tests -4. `npm run build` - Build for production +This command runs all quality checks and builds the project. All checks must pass before code is considered ready. -All checks must pass before code is considered ready for deployment. - -**CI/CD Pipeline Example:** -```yaml -# Example for GitHub Actions, GitLab CI, etc. -- npm run ci # Runs all checks and build -``` - -### Building for Production - -Create an optimized production build: - -```bash -npm run build -``` - -This command: -1. Compiles TypeScript to JavaScript -2. Bundles and minifies all code -3. Optimizes images and assets -4. Generates static HTML files -5. Outputs everything to `build/` directory - -**Generated files location:** -``` -build/ -├── client/ # Everything needed for the website -│ ├── *.html # Pre-rendered HTML pages -│ ├── assets/ # Optimized JavaScript and CSS -│ │ ├── *.js # JavaScript bundles (minified) -│ │ ├── *.css # Stylesheets (minified) -│ │ └── manifest-*.js # Asset manifest -│ └── images/ # Optimized images -└── server/ # Server-side code (if applicable) -``` - -The `build/client/` directory contains everything needed to deploy the website to any static file host. +Generated files are located under the `build/` directory. ### Maven Integration @@ -394,6 +424,7 @@ mvn site ``` The website will **NOT** build during regular commands like: + - `mvn clean install` - `mvn package` - `mvn compile` @@ -424,7 +455,11 @@ When you run `mvn site`, the website module automatically: 5. **Runs `npm run ci`** which executes: - `npm run lint` - ESLint code quality checks - `npm run typecheck` - TypeScript type checking - - `npm run test:run` - Vitest unit tests + - `npm run extract-developers` - Extract developers from parent pom.xml + - `npm run extract-hbase-config` - Extract data from `hbase-default.xml` to `app/pages/_docs/docs/_mdx/(multi-page)/configuration/hbase-default.md` + - `npm run extract-hbase-version` - Extract version from root `pom.xml` to `app/lib/export-pdf/hbase-version.json` + - `npm run test:unit:run` - Vitest unit tests + - `npm run test:e2e` - Playwright e2e tests - `npm run build` - Production build 6. **Build Output**: Generated files are in `build/` directory @@ -432,12 +467,14 @@ When you run `mvn site`, the website module automatically: #### Maven Commands **Build HBase WITHOUT the Website (default):** + ```bash # From HBase root directory mvn clean install ``` **Build the Website:** + ```bash # From HBase root or hbase-website directory mvn site @@ -446,87 +483,20 @@ mvn site This generates the full HBase website including documentation and the React-based website. **Build Website Only:** -```bash -# From hbase-website directory -cd hbase-website -mvn site -``` - -#### Maven Lifecycle Phases - -The frontend-maven-plugin binds to these **site-specific** Maven phases: - -- **pre-site**: Installs Node.js/npm, runs `npm install`, and extracts developers data -- **site**: Runs `npm run ci` (lint, typecheck, test, build) - -#### Integration with CI/CD - -The Maven configuration ensures consistent builds across different environments: - -- **Local Development**: Developers can build HBase with `mvn clean install` (website not included) -- **Website Generation**: Use `mvn site` to generate the full website and documentation -- **CI/CD Pipelines**: Automated builds work out-of-the-box with Maven -- **No Manual Steps**: No need to manually run `npm install` or `npm run ci` when using `mvn site` - -#### Maven Troubleshooting - -**Build Fails During npm install:** ```bash -# Clean and rebuild -cd hbase-website -mvn clean site -``` - -This will: -- Remove `build/` directory -- Remove `node_modules/` directory -- Remove `target/` directory -- Reinstall Node.js and npm -- Install all dependencies fresh -- Run the full build pipeline - -**Build Fails During npm run ci:** - -This usually indicates: -- ESLint errors (code quality issues) -- TypeScript type errors -- Failing unit tests -- Build configuration issues - -To diagnose, run the commands directly: -```bash +# From hbase-website directory cd hbase-website -npm install -npm run lint # Check linting -npm run typecheck # Check types -npm run test:run # Check tests -npm run build # Check build +mvn clean install ``` -Fix any errors and try the Maven build again. - -#### Configuration Files - -- **pom.xml**: Maven configuration using frontend-maven-plugin -- **package.json**: npm scripts and dependencies -- **.gitignore**: Excludes `target/`, `node/`, `node_modules/`, `build/` - -#### For HBase Developers - -The website only builds when you explicitly run `mvn site`. +**Skip Website Build:** -If you're working on the website: +If you want to build HBase but skip the website: ```bash -# Use npm for faster development iteration -cd hbase-website -npm install -npm run dev # Start dev server with hot reload - -# Or use Maven to build the website -cd hbase-website -mvn site +# From HBase root directory +mvn clean install -DskipSite ``` ### Deployment @@ -560,15 +530,24 @@ lsof -ti:5173 | xargs kill -9 # Or change the port in vite.config.ts ``` -#### Need to Clean Everything +#### Build Fails -Nuclear option - removes all generated files: +1. **Clear generated files:** -```bash -rm -rf node_modules/ build/ .react-router/ -npm install -npm run build -``` + ```bash + rm -rf build/ node_modules/ .vite/ .react-router/ .source/ + ``` + +2. **Reinstall dependencies:** + + ```bash + npm i + ``` + +3. **Try building again:** + ```bash + npm run build + ``` --- diff --git a/hbase-website/app/app.css b/hbase-website/app/app.css index 30bf9ef10d7e..aff3be404809 100644 --- a/hbase-website/app/app.css +++ b/hbase-website/app/app.css @@ -18,9 +18,11 @@ @import "tailwindcss"; @import "tw-animate-css"; -@import "highlight.js/styles/github.css"; +@import "fumadocs-ui/css/shadcn.css"; +@import "fumadocs-ui/css/preset.css"; -@theme { +@plugin "@tailwindcss/typography" { + className: prose-original; } @custom-variant dark (&:is(.dark *)); @@ -169,6 +171,10 @@ font-family: "Inter", sans-serif; font-synthesis: none; } + + .print-only { + display: none; + } } @layer utilities { @@ -179,33 +185,103 @@ } } -/* Code syntax highlighting for dark mode */ -.dark pre code.hljs { - background: oklch(0.2 0 0); - color: oklch(0.85 0 0); -} +/* For rendering a PDF */ +@media print { + @page { + size: A4; + } -.dark .hljs-comment { - color: oklch(0.55 0 0); -} + /* Utility classes */ + .print-only { + display: block !important; + } + .no-print { + display: none !important; + } -.dark .hljs-keyword { - color: oklch(0.75 0.12 340); -} + /* Hide layout elements */ + #nd-docs-layout { + --fd-sidebar-width: 0px !important; + } + #nd-sidebar { + display: none; + } + header { + display: none !important; + } + /* Hide copy button in the code blocks */ + figure > div > button { + display: none !important; + } -.dark .hljs-string { - color: oklch(0.75 0.1 140); -} + /* Every major section starts on a new page */ + h1:not(:first-of-type) { + break-before: page; + margin-top: 0; /* Remove top margin since it's at the top of a page now */ + } -.dark .hljs-number { - color: oklch(0.75 0.1 100); -} + h1, + h2, + h3, + h4, + h5, + h6 { + break-after: avoid; + break-inside: avoid; + } -.dark .hljs-title { - color: oklch(0.75 0.12 260); -} + /* Hides the
line */ + hr { + display: none; + } + + /* Force tables to wrap the content */ + table { + max-width: 100%; + table-layout: fixed; + word-wrap: break-word; + } + th, + td { + padding: 6px 10px !important; + word-break: break-all; /* Older browsers */ + overflow-wrap: anywhere; /* Modern browsers: breaks at any character */ + hyphens: auto; /* Adds a hyphen if the language is defined */ + } + thead { + display: table-header-group; /* Re-renders header on every new page */ + } + /* Don't split a row on two pages, just move it to the next page */ + tr { + break-inside: avoid; + } + + /* Force code blocks to wrap instead of scroll */ + pre, + code { + max-width: 100% !important; + white-space: pre-wrap !important; + word-break: break-all !important; + overflow-wrap: break-word !important; + } -.dark .hljs-name, -.dark .hljs-attribute { - color: oklch(0.7 0.12 200); + /* Targets Code and Tables */ + pre, + table, + blockquote { + /* Try to keep it on one page if possible */ + break-inside: auto; + /* If it breaks, ensure at least 3 lines stay on the current page */ + orphans: 3; + /* If it breaks, ensure at least 3 lines move to the next page */ + widows: 3; + } + + /* Remove the height restriction and scrolling from the code blocks */ + figure .fd-scroll-container.max-h-\[600px\] { + max-height: none !important; + overflow: visible !important; + height: auto !important; + display: block !important; + } } 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/getting-started.tsx b/hbase-website/app/components/getting-started.tsx index 2071000829ac..60fd715cc4be 100644 --- a/hbase-website/app/components/getting-started.tsx +++ b/hbase-website/app/components/getting-started.tsx @@ -29,12 +29,12 @@ export function GettingStartedSection() { { title: "2. Read the Guide", desc: "Walk through cluster setup, schema design, and operations.", - to: "https://hbase.apache.org/book.html#_get_started_with_hbase" + to: "/docs/getting-started#get-started-with-hbase" }, { title: "3. Connect a Client", desc: "Use the Java API, REST, or Thrift to start building.", - to: "https://hbase.apache.org/book.html#hbase_apis" + to: "/docs/hbase-apis" } ]; return ( diff --git a/hbase-website/app/components/links.ts b/hbase-website/app/components/links.ts index ec6a9e3ddb61..68764015d80f 100644 --- a/hbase-website/app/components/links.ts +++ b/hbase-website/app/components/links.ts @@ -16,6 +16,8 @@ // limitations under the License. // +import { fileNameVariants } from "@/lib/export-pdf/export-pdf-types"; + interface LinkType { label: string; to: string; @@ -64,7 +66,6 @@ export const projectLinks: LinkType[] = [ label: "Export Control", to: "/export-control" }, - { label: "Other Resources", to: "/other-resources" @@ -74,11 +75,17 @@ 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)", - to: "https://hbase.apache.org/apache_hbase_reference_guide.pdf" + to: `/books/${fileNameVariants.light}`, + external: true + }, + { + label: "Reference Guide (Dark PDF)", + to: `/books/${fileNameVariants.dark}`, + external: true }, { label: "中文参考指南(单页)", @@ -104,7 +111,7 @@ export const documentationLinks: (LinkType | NestedLinkType)[] = [ links: [ { label: "Video/Presentations", - to: "https://hbase.apache.org/book.html#other.info" + to: "/docs/other-info" }, { label: "ACID Semantics", @@ -112,11 +119,11 @@ export const documentationLinks: (LinkType | NestedLinkType)[] = [ }, { label: "Bulk Loads", - to: "https://hbase.apache.org/book.html#arch.bulk.load" + to: "/docs/architecture/bulk-loading" }, { label: "Metrics", - to: "https://hbase.apache.org/book.html#hbase_metrics" + to: "/docs/operational-management/metrics-and-monitoring" } ] } diff --git a/hbase-website/app/components/markdown-layout.tsx b/hbase-website/app/components/markdown-layout.tsx deleted file mode 100644 index f34886cc5533..000000000000 --- a/hbase-website/app/components/markdown-layout.tsx +++ /dev/null @@ -1,162 +0,0 @@ -// -// 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 Markdown, { type Components } from "react-markdown"; -import { Link } from "@/components/link"; -import remarkGfm from "remark-gfm"; -import rehypeSlug from "rehype-slug"; -import rehypeAutolinkHeadings from "rehype-autolink-headings"; -import rehypeHighlight from "rehype-highlight"; -import rehypeRaw from "rehype-raw"; -import { ExternalLinkIcon } from "lucide-react"; - -interface MarkdownLayoutProps { - children: string; - autoLinkHeadings?: boolean; - highlight?: boolean; - components?: Components; -} - -export function MarkdownLayout({ - children, - autoLinkHeadings = false, - highlight = true, - components: customComponents -}: MarkdownLayoutProps) { - const rehypePlugins: any[] = [rehypeRaw]; - if (autoLinkHeadings) { - rehypePlugins.push(rehypeSlug, [rehypeAutolinkHeadings, { behavior: "wrap" }]); - } - if (highlight) { - // ignoreMissing avoids errors if someone writes `language-xyz` you don't ship - rehypePlugins.push([rehypeHighlight, { ignoreMissing: true }]); - } - - return ( -
-
- ( -

- {children} -

- ), - h2: ({ children }) => ( -

- {children} -

- ), - h3: ({ children }) => ( -

- {children} -

- ), - p: ({ children }) =>

{children}

, - a: ({ href, children }) => { - const isExternal = - href?.startsWith("http") && !href?.startsWith("https://hbase.apache.org/"); - - // Check if the link contains only an image (no external icon needed) - const hasOnlyImage = Array.isArray(children) - ? children.every((child) => child?.type === "img" || typeof child === "object") - : typeof children === "object"; - - return isExternal ? ( - - {children} - {!hasOnlyImage && ( - <> - {"\u00A0"} - - - )} - - ) : ( - - {children} - - ); - }, - ol: ({ children }) =>
    {children}
, - ul: ({ children }) =>
    {children}
, - li: ({ children }) =>
  • {children}
  • , - // Keep code/pre lean so highlight.js classes (`hljs ...`) can style properly - code: ({ children, className }) => { - const isInline = !className; - if (isInline) { - return ( - - {children} - - ); - } - return ( - {children} - ); - }, - pre: ({ children }) => ( -
    {children}
    - ), - blockquote: ({ children }) => ( -
    - {children} -
    - ), - img: ({ src, alt }) => ( - {alt - ), - table: ({ children }) => ( -
    - {children}
    -
    - ), - thead: ({ children }) => {children}, - tbody: ({ children }) => {children}, - tr: ({ children }) => ( - - {children} - - ), - th: ({ children }) => {children}, - td: ({ children }) => {children}, - ...customComponents - }} - > - {children} -
    -
    -
    - ); -} diff --git a/hbase-website/app/components/mdx-components.tsx b/hbase-website/app/components/mdx-components.tsx new file mode 100644 index 000000000000..b34a7e6a0e02 --- /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) =>