diff --git a/.codex/skills/website-docs-url-mapping/SKILL.md b/.codex/skills/website-docs-url-mapping/SKILL.md
new file mode 100644
index 000000000..35f4fffa0
--- /dev/null
+++ b/.codex/skills/website-docs-url-mapping/SKILL.md
@@ -0,0 +1,69 @@
+---
+name: website-docs-url-mapping
+description: Modify URL mapping rules in the website-docs Gatsby site by editing gatsby/url-resolver/config.ts and gatsby/link-resolver/config.ts, updating Jest tests, updating gatsby/URL_MAPPING_ARCHITECTURE.md, and reviewing gatsby-plugin-react-i18next matchPath when URL prefixes or languages change.
+---
+
+# Website-docs URL Mapping
+
+## Overview
+
+Modify the mapping rules between docs source paths and site URLs, and resolve internal Markdown links, while keeping tests and docs in sync.
+
+## Workflow
+
+### 1) Gather Requirements (Clarify First)
+
+Ask the user for “input → expected output” examples (at least 3 of each; more is better):
+- Page URLs: `sourcePath/slug -> pageUrl` (whether to omit default language `/en/`, and trailing slash expectations)
+- Link resolution: `(linkPath, currentPageUrl) -> resolvedUrl` (include edge cases: hash, no leading slash, relative paths, etc.)
+
+### 2) Review Existing Design (Align Terms and Current Behavior)
+
+Open and quickly locate the rules sections:
+- `gatsby/URL_MAPPING_ARCHITECTURE.md` (Configuration Rules)
+- `gatsby/url-resolver/README.md` (pattern/alias/filenameTransform)
+- `gatsby/link-resolver/README.md` (direct vs path-based, conditions/pathConditions)
+
+### 3) Edit URL Resolver (Page URLs)
+
+Edit: `gatsby/url-resolver/config.ts`
+- `pathMappings` are matched in order (first match wins); new rules usually go before more general ones.
+- `sourcePattern` supports `{var}` and `{...var}`; `conditions` use extracted variables to decide applicability.
+- Use `filenameTransform` to handle `_index` / `_docHome` (ignore filename or switch `targetPattern`).
+- If branch display logic changes, update `aliases` as well (e.g. `{branch:branch-alias-tidb}`).
+
+### 4) Edit Link Resolver (Markdown Links)
+
+Edit: `gatsby/link-resolver/config.ts`
+- Direct mapping: only `linkPattern` (does not depend on the current page)
+- Path-based mapping: `pathPattern + linkPattern`, constrained by `pathConditions`
+- Use `namespaceTransform` for namespace migrations (e.g. `develop -> developer`)
+- Watch `defaultLanguage` omission logic and `url-resolver.trailingSlash` (tests should cover both)
+
+### 5) Update/Add Tests (Prevent Regressions)
+
+- `gatsby/url-resolver/__tests__/url-resolver.test.ts`
+- `gatsby/link-resolver/__tests__/link-resolver.test.ts`
+
+Coverage (at minimum, every new/changed rule has assertions):
+- New rule match vs fallback (ordering)
+- `en/zh/ja` + whether `/en/` is omitted
+- `_index` vs non-`_index`
+- Links: preserve hash, no leading slash, path depth, multi-segment prefixes, etc.
+
+### 6) Run Tests
+
+- Full suite: `yarn test`
+- Or run a single test file first: `yarn test gatsby/url-resolver/__tests__/url-resolver.test.ts`, `yarn test gatsby/link-resolver/__tests__/link-resolver.test.ts`
+
+### 7) Update Architecture Doc (Keep It In Sync)
+
+Edit: `gatsby/URL_MAPPING_ARCHITECTURE.md`
+- Update interpretations under “URL Resolver Configuration Rules” and “Link Resolver Configuration Rules”
+- Keep rule numbering/order consistent with `config.ts`, and update input/output examples
+
+### 8) Check i18n Routing (Often Required When URL Prefixes Change)
+
+Review: `gatsby-config.js` → `gatsby-plugin-react-i18next`:
+- Ensure `languages` matches supported site languages (currently `en/zh/ja`)
+- Ensure `pages[].matchPath` includes any new/renamed top-level path prefixes (e.g. `developer`, `best-practices`, `api`, `releases`, and repo keys in `docs/docs.json`)
diff --git a/.github/workflows/sync-nextgen.yml b/.github/workflows/sync-nextgen.yml
new file mode 100644
index 000000000..9037a06d3
--- /dev/null
+++ b/.github/workflows/sync-nextgen.yml
@@ -0,0 +1,63 @@
+name: sync-nextgen
+
+on:
+ push:
+ branches:
+ - "feat/restructure"
+
+concurrency:
+ group: sync-nextgen
+ cancel-in-progress: true
+
+permissions:
+ contents: write
+ actions: write
+
+jobs:
+ sync:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Configure git
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+
+ - name: Merge feat/restructure into preview-nextgen
+ env:
+ SOURCE_BRANCH: "feat/restructure"
+ TARGET_BRANCH: "preview-nextgen"
+ run: |
+ set -euo pipefail
+
+ git fetch origin "${SOURCE_BRANCH}" --prune
+ git fetch origin "${TARGET_BRANCH}" --prune || true
+
+ if git show-ref --verify --quiet "refs/remotes/origin/${TARGET_BRANCH}"; then
+ git switch -c "${TARGET_BRANCH}" "origin/${TARGET_BRANCH}" || git switch "${TARGET_BRANCH}"
+ else
+ # Create the target branch the first time if it doesn't exist yet.
+ git switch -c "${TARGET_BRANCH}" "origin/${SOURCE_BRANCH}"
+ fi
+
+ git merge --no-ff "origin/${SOURCE_BRANCH}" -m "sync-nextgen: merge ${SOURCE_BRANCH} into ${TARGET_BRANCH}"
+ git push origin "${TARGET_BRANCH}"
+
+ - name: Dispatch production workflow on preview-nextgen
+ uses: actions/github-script@v7
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ await github.rest.actions.createWorkflowDispatch({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ workflow_id: "production.yml",
+ ref: "preview-nextgen",
+ inputs: {
+ hash: "nextgen",
+ },
+ });
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 000000000..f9cb49efd
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,46 @@
+# Repository Guidelines
+
+## Project Structure & Module Organization
+
+- `src/`: Gatsby site code (React components, templates, state, theme, and styles).
+- `gatsby/`: build-time utilities (page creation, link/url resolvers, custom plugins) and unit tests.
+- `docs/`: documentation content (git submodule pointing to `pingcap/docs-staging`).
+- `locale/`: i18n dictionaries (`locale/{en,zh,ja}/translation.json`).
+- `static/`, `src/media/`, `images/`: static assets used by the site/README.
+- Generated (do not commit): `.cache/`, `public/`, `coverage/`.
+
+## Build, Test, and Development Commands
+
+- `yarn`: install dependencies and apply `patches/` via `patch-package`.
+- `git submodule update --init --depth 1 --remote`: fetch/update the docs submodule content.
+- `yarn start` (or `yarn dev`): run local development server (Gatsby develop).
+- `yarn build`: create a production build.
+- `yarn serve`: serve the production build locally.
+- `yarn clean`: remove Gatsby build caches (`.cache/`, `public/`).
+- `yarn test`: run Jest with coverage for code under `gatsby/`.
+
+## Coding Style & Naming Conventions
+
+- Indentation: 2 spaces (see `.editorconfig`); keep TypeScript `strict` passing.
+- Formatting: Prettier runs via Husky + lint-staged on commit (`.husky/pre-commit`).
+- Components: PascalCase folders/files (e.g. `src/components/Layout/`); utilities/hooks: camelCase.
+- Styles: prefer CSS Modules (`*.module.css`); shared CSS in `src/styles/*.css`.
+- Imports: `tsconfig.json` sets `baseUrl: "./src"` (absolute imports like `shared/utils/...` are preferred).
+
+## Testing Guidelines
+
+- Framework: Jest + `ts-jest` (`jest.config.js`).
+- Location/pattern: `gatsby/**/__tests__/**/*.test.{ts,tsx,js,jsx}`.
+- Add/adjust tests when changing resolver logic or Gatsby build utilities.
+
+## Commit & Pull Request Guidelines
+
+- Commit messages follow a Conventional Commits pattern (common types: `feat:`, `fix:`, `refactor:`, `chore:`).
+- PRs should include: clear description + rationale, linked issue(s), and screenshots for UI changes.
+- Before requesting review, ensure `yarn test` and `yarn build` pass locally.
+
+## Security & Configuration Tips
+
+- Put local-only env vars in `.env.development` (e.g. `GATSBY_ALGOLIA_APPLICATION_ID`, `GATSBY_ALGOLIA_API_KEY`).
+- If GitHub API rate-limits during development, set `GITHUB_AUTHORIZATION_TOKEN=...` when running commands.
+- Never commit `.env*` files (they are gitignored).
diff --git a/gatsby-config.js b/gatsby-config.js
index a0d7fdae5..9558c88ef 100644
--- a/gatsby-config.js
+++ b/gatsby-config.js
@@ -79,7 +79,9 @@ module.exports = {
getLanguageFromPath: false,
},
{
- matchPath: `/:lang?/(${Object.keys(docs.docs).join("|")})/(.*)`,
+ matchPath: `/:lang?/(${Object.keys(docs.docs).join(
+ "|"
+ )}|developer|best-practices|api|ai|releases)/(.*)`,
getLanguageFromPath: true,
},
{
diff --git a/gatsby-node.js b/gatsby-node.js
index 6d01d9062..184065bb0 100644
--- a/gatsby-node.js
+++ b/gatsby-node.js
@@ -8,7 +8,7 @@ const {
createDocSearch,
create404,
} = require("./gatsby/create-pages");
-const { createExtraType } = require("./gatsby/create-types");
+const { createFrontmatter, createNavs } = require("./gatsby/create-types");
const {
createConditionalToc,
} = require("./gatsby/plugin/conditional-toc/conditional-toc");
@@ -26,6 +26,7 @@ exports.createPages = async ({ graphql, actions }) => {
};
exports.createSchemaCustomization = (options) => {
- createExtraType(options);
+ createFrontmatter(options);
+ createNavs(options);
createConditionalToc(options);
};
diff --git a/gatsby/URL_MAPPING_ARCHITECTURE.md b/gatsby/URL_MAPPING_ARCHITECTURE.md
new file mode 100644
index 000000000..6efaeb7dc
--- /dev/null
+++ b/gatsby/URL_MAPPING_ARCHITECTURE.md
@@ -0,0 +1,539 @@
+# URL Mapping Architecture
+
+## Overview
+
+This document describes how the project handles URL mapping across three key areas:
+1. **Page URL Mapping**: Converting source file paths to published page URLs during build
+2. **TOC Mapping**: Resolving links in TOC (Table of Contents) files
+3. **Article Link Mapping**: Transforming internal links within markdown articles
+
+The system uses two core resolvers (`url-resolver` and `link-resolver`) that work together to provide a consistent, maintainable URL structure throughout the documentation site.
+
+## Architecture Flow
+
+### 1. Page URL Mapping (Build Time)
+
+**Location**: `gatsby/create-pages/create-docs.ts`
+
+**Process**:
+1. Gatsby queries all MDX files from the GraphQL data layer
+2. For each file, `calculateFileUrl()` from `url-resolver` converts the source path to a published URL
+3. The resolved URL is used to create the Gatsby page with `createPage()`
+
+**Example**:
+```typescript
+// Source file: docs/markdown-pages/en/tidb/master/alert-rules.md
+// Slug: "en/tidb/master/alert-rules"
+const path = calculateFileUrl(node.slug, true);
+// Result: "/tidb/dev/alert-rules"
+// Creates page at: /tidb/dev/alert-rules
+```
+
+**Key Points**:
+- Uses `url-resolver` to transform source paths to URLs
+- Default language (`en`) is omitted from URLs (`omitDefaultLanguage: true`)
+- Only files referenced in TOC files are built (filtered by `filterNodesByToc`)
+
+### 2. TOC Mapping (Build Time)
+
+**Location**: `gatsby/toc.ts` and `gatsby/toc-filter.ts`
+
+**Process**:
+1. Gatsby queries all TOC files (files matching `/TOC.*md$/`)
+2. For each TOC file, `mdxAstToToc()` parses the markdown AST
+3. Links within TOC are resolved using `resolveMarkdownLink()` from `link-resolver`
+4. The resolved TOC structure is used to:
+ - Determine which files should be built (`getFilesFromTocs`)
+ - Generate navigation menus for pages
+
+**Example**:
+```typescript
+// TOC file: docs/markdown-pages/en/tidb/stable/TOC.md
+// Contains link: [Getting Started](/develop/getting-started)
+// TOC path: "/en/tidb/stable" (resolved from TOC file slug)
+const resolvedLink = resolveMarkdownLink("/develop/getting-started", "/en/tidb/stable");
+// Result: "/developer/getting-started"
+// Used in navigation menu
+```
+
+**Key Points**:
+- Uses `link-resolver` to resolve links in TOC files
+- TOC links are resolved relative to the TOC file's own URL
+- Resolved links are used to build a whitelist of files to include in the build
+
+### 3. Article Link Mapping (Build Time)
+
+**Location**: `gatsby/plugin/content/index.ts`
+
+**Process**:
+1. During markdown processing, Gatsby's MDX plugin processes each article
+2. For each link in the markdown AST, `resolveMarkdownLink()` resolves the link path
+3. The resolved link is converted to a Gatsby `` component
+4. External links (`http://`, `https://`) are kept as-is with `target="_blank"`
+
+**Example**:
+```typescript
+// Article: docs/markdown-pages/en/tidb/stable/overview.md
+// Contains link: [Upgrade Guide](/upgrade/upgrade-tidb-using-tiup)
+// Current page URL: "/tidb/stable/overview" (resolved from file path)
+const resolvedPath = resolveMarkdownLink(
+ "/upgrade/upgrade-tidb-using-tiup",
+ "/tidb/stable/overview"
+);
+// Result: "/tidb/stable/upgrade-tidb-using-tiup"
+// Rendered as: Upgrade Guide
+```
+
+**Key Points**:
+- Uses `link-resolver` to resolve links based on current page context
+- Links are resolved relative to the current article's URL
+- Hash fragments (`#section`) are preserved automatically
+
+## How They Connect
+
+### Build Process Flow
+
+```
+1. GraphQL Query
+ └─> Query all MDX files (including TOC files)
+
+2. TOC Processing (toc-filter.ts)
+ ├─> Parse TOC files
+ ├─> Resolve TOC links (link-resolver)
+ └─> Build file whitelist (locale/repo/version -> Set)
+
+3. Page Creation (create-docs.ts)
+ ├─> Filter files by TOC whitelist
+ ├─> Resolve page URLs (url-resolver)
+ ├─> Determine namespace (getTOCNamespace)
+ └─> Create Gatsby pages
+
+4. Content Processing (plugin/content/index.ts)
+ ├─> Process markdown AST
+ ├─> Resolve article links (link-resolver)
+ └─> Convert to JSX components
+```
+
+### Data Flow
+
+```
+Source File Path
+ ↓
+[url-resolver] → Published Page URL
+ ↓
+Page Context (pageUrl, namespace, etc.)
+ ↓
+[link-resolver] → Resolved Links (in TOC and articles)
+ ↓
+Final HTML/JSX
+```
+
+### Example: Complete Flow
+
+**Scenario**: Building a TiDB article with links
+
+1. **Source File**: `docs/markdown-pages/en/tidb/master/alert-rules.md`
+ - Contains link: `[Vector Search](/develop/vector-search)`
+
+2. **Page URL Resolution** (`create-docs.ts`):
+ ```typescript
+ const pageUrl = calculateFileUrl("en/tidb/master/alert-rules", true);
+ // Result: "/tidb/dev/alert-rules"
+ ```
+
+3. **TOC Processing** (`toc-filter.ts`):
+ - TOC file: `en/tidb/stable/TOC.md`
+ - Contains link to `alert-rules`
+ - Link resolved: `/tidb/dev/alert-rules`
+ - File added to whitelist: `en/tidb/stable -> Set(["alert-rules"])`
+
+4. **Page Creation** (`create-docs.ts`):
+ - File matches TOC whitelist → page is created
+ - Page URL: `/tidb/dev/alert-rules`
+ - Namespace: `TOCNamespace.TiDB`
+
+5. **Content Processing** (`plugin/content/index.ts`):
+ - Current page URL: `/en/tidb/dev/alert-rules`
+ - Link `/develop/vector-search` resolved:
+ ```typescript
+ resolveMarkdownLink("/develop/vector-search", "/en/tidb/dev/alert-rules")
+ // Result: "/developer/vector-search"
+ ```
+ - Rendered as: `Vector Search`
+
+## Configuration Rules
+
+The following sections describe the effects of each configuration rule in order of evaluation.
+
+---
+
+## URL Resolver Configuration Rules
+
+Rules are evaluated in order; the first matching rule wins.
+
+### Rule 1: TiDBCloud Dedicated Index
+
+**Effect**: Maps TiDBCloud dedicated `_index.md` files to the TiDBCloud root URL.
+
+**Source Pattern**: `/{lang}/tidbcloud/master/tidb-cloud/dedicated/{filename}`
+
+**Target Pattern**: `/{lang}/tidbcloud`
+
+**Conditions**: `filename = "_index"`
+
+**Example**:
+- Source: `en/tidbcloud/master/tidb-cloud/dedicated/_index.md`
+- Target: `/tidbcloud` (or `/en/tidbcloud` if default language not omitted)
+
+**Use Case**: The dedicated plan index page is served at the TiDBCloud root.
+
+---
+
+### Rule 2: TiDBCloud Releases Index
+
+**Effect**: Maps TiDBCloud releases `_index.md` to the releases namespace.
+
+**Source Pattern**: `/{lang}/tidbcloud/master/tidb-cloud/releases/{filename}`
+
+**Target Pattern**: `/{lang}/releases/tidb-cloud`
+
+**Conditions**: `filename = "_index"`
+
+**Example**:
+- Source: `en/tidbcloud/master/tidb-cloud/releases/_index.md`
+- Target: `/releases/tidb-cloud`
+
+**Use Case**: TiDBCloud releases are grouped under the shared releases namespace.
+
+---
+
+### Rule 3: TiDB Releases Index
+
+**Effect**: Maps TiDB stable branch releases `_index.md` to the releases namespace.
+
+**Source Pattern**: `/{lang}/tidb/{stable}/releases/{filename}`
+
+**Target Pattern**: `/{lang}/releases/tidb-self-managed`
+
+**Conditions**: `filename = "_index"`
+
+**Example**:
+- Source: `en/tidb/release-8.5/releases/_index.md`
+- Target: `/releases/tidb-self-managed`
+
+**Use Case**: TiDB releases are grouped under the shared releases namespace.
+
+---
+
+### Rule 4: TiDB-in-Kubernetes Releases Index
+
+**Effect**: Maps TiDB-in-Kubernetes releases `_index.md` to the releases namespace.
+
+**Source Pattern**: `/{lang}/tidb-in-kubernetes/main/releases/{filename}`
+
+**Target Pattern**: `/{lang}/releases/tidb-operator`
+
+**Conditions**: `filename = "_index"`
+
+**Example**:
+- Source: `en/tidb-in-kubernetes/main/releases/_index.md`
+- Target: `/releases/tidb-operator`
+
+**Use Case**: TiDB-in-Kubernetes releases are grouped under the shared releases namespace.
+
+---
+
+### Rule 5: TiDBCloud with Prefix
+
+**Effect**: Maps TiDBCloud pages with prefixes (dedicated, starter, essential) to TiDBCloud URLs.
+
+**Source Pattern**: `/{lang}/tidbcloud/{branch}/tidb-cloud/{...prefixes}/{filename}`
+
+**Target Pattern**:
+- For `_index`: `/{lang}/tidbcloud/{prefixes}` (keeps prefixes)
+- For other files: `/{lang}/tidbcloud/{filename}` (removes prefixes)
+
+**Filename Transform**:
+- `ignoreIf: ["_index"]` - Filename removed from URL for non-index files
+- `conditionalTarget.keepIf: ["_index"]` - Uses alternative pattern for `_index` files
+
+**Example**:
+- Source: `en/tidbcloud/master/tidb-cloud/dedicated/starter/_index.md`
+- Target: `/tidbcloud/dedicated/starter`
+- Source: `en/tidbcloud/master/tidb-cloud/dedicated/starter/getting-started.md`
+- Target: `/tidbcloud/getting-started`
+
+**Use Case**: TiDBCloud has multiple plan types (dedicated, starter, essential) with different URL structures.
+
+---
+
+### Rule 6: Developer/Best-Practices/API/AI Namespace
+
+**Effect**: Maps TiDB pages in `develop` (published as `developer`), `best-practices`, `api`, or `ai` folders to namespace URLs.
+
+**Source Pattern**: `/{lang}/tidb/{stable}/{folder}/{...folders}/{filename}`
+
+**Target Pattern**:
+- For `develop` folder:
+ - `_index`: `/{lang}/developer/{folders}` (keeps folder structure)
+ - Other files: `/{lang}/developer/{filename}` (removes folder structure)
+- For `best-practices`, `api`, and `ai` folders:
+ - `_index`: `/{lang}/{folder}/{folders}` (keeps folder structure)
+ - Other files: `/{lang}/{folder}/{filename}` (removes folder structure)
+
+**Conditions**:
+- `folder = ["develop"]` → published under `/developer`
+- `folder = ["best-practices", "api", "ai"]` → published under `/{folder}`
+
+**Filename Transform**:
+- `ignoreIf: ["_index"]` - Filename removed from URL for non-index files
+- `conditionalTarget.keepIf: ["_index"]` - Uses alternative pattern for `_index` files
+
+**Example**:
+- Source: `en/tidb/release-8.5/develop/subfolder/_index.md`
+- Target: `/developer/subfolder`
+- Source: `en/tidb/release-8.5/develop/subfolder/vector-search.md`
+- Target: `/developer/vector-search`
+- Source: `en/tidb/release-8.5/ai/overview.md`
+- Target: `/ai/overview`
+
+**Use Case**: These namespaces are shared across all TiDB versions, so folder structure is flattened for non-index files.
+
+---
+
+### Rule 7: TiDB with Branch Alias
+
+**Effect**: Maps TiDB pages with branch aliasing (master → dev, release-* → v*).
+
+**Source Pattern**: `/{lang}/tidb/{branch}/{...folders}/{filename}`
+
+**Target Pattern**: `/{lang}/tidb/{branch:branch-alias-tidb}/{filename}`
+
+**Filename Transform**: `ignoreIf: ["_index", "_docHome"]`
+
+**Alias Mapping** (`branch-alias-tidb`):
+- `master` → `dev`
+- `{stable}` → `stable` (exact match)
+- `release-*` → `v*` (wildcard pattern)
+
+**Example**:
+- Source: `en/tidb/master/alert-rules.md`
+- Target: `/tidb/dev/alert-rules`
+- Source: `en/tidb/release-8.5/alert-rules.md`
+- Target: `/tidb/v8.5/alert-rules`
+
+**Use Case**: Branch names are transformed to user-friendly version identifiers.
+
+---
+
+### Rule 8: TiDB-in-Kubernetes with Branch Alias
+
+**Effect**: Maps TiDB-in-Kubernetes pages with branch aliasing (main → dev, release-* → v*).
+
+**Source Pattern**: `/{lang}/tidb-in-kubernetes/{branch}/{...folders}/{filename}`
+
+**Target Pattern**: `/{lang}/tidb-in-kubernetes/{branch:branch-alias-tidb-in-kubernetes}/{filename}`
+
+**Filename Transform**: `ignoreIf: ["_index", "_docHome"]`
+
+**Alias Mapping** (`branch-alias-tidb-in-kubernetes`):
+- `main` → `dev`
+- `{stable}` → `stable` (exact match)
+- `release-*` → `v*` (wildcard pattern)
+
+**Example**:
+- Source: `en/tidb-in-kubernetes/main/deploy/deploy-tidb-on-kubernetes.md`
+- Target: `/tidb-in-kubernetes/dev/deploy-tidb-on-kubernetes`
+- Source: `en/tidb-in-kubernetes/release-1.6/deploy/deploy-tidb-on-kubernetes.md`
+- Target: `/tidb-in-kubernetes/v1.6/deploy-tidb-on-kubernetes`
+
+**Use Case**: Branch names are transformed to user-friendly version identifiers.
+
+---
+
+### Rule 9: Fallback Rule
+
+**Effect**: Generic fallback for any remaining paths.
+
+**Source Pattern**: `/{lang}/{repo}/{...any}/{filename}`
+
+**Target Pattern**: `/{lang}/{repo}/{filename}`
+
+**Filename Transform**: `ignoreIf: ["_index", "_docHome"]`
+
+**Example**:
+- Source: `en/dm/release-5.3/migration/migrate-data.md`
+- Target: `/en/dm/migrate-data`
+
+**Use Case**: Catches any paths that don't match previous rules.
+
+---
+
+## Link Resolver Configuration Rules
+
+Rules are evaluated in order; the first matching rule wins.
+
+### Rule 1: Releases Index Links
+
+**Effect**: Resolves `/releases/_index` links to TiDB self-managed releases page.
+
+**Link Pattern**: `/releases/_index`
+
+**Target Pattern**: `/{curLang}/releases/tidb-self-managed`
+
+**Example**:
+- Link: `/releases/_index`
+- Current Page: Any page
+- Result: `/releases/tidb-self-managed` (or `/en/releases/tidb-self-managed` if default language not omitted)
+
+**Use Case**: Direct links to TiDB releases page.
+
+---
+
+### Rule 2: TiDB Cloud Releases Index Links
+
+**Effect**: Resolves `/tidb-cloud/releases/_index` links to TiDB Cloud releases page.
+
+**Link Pattern**: `/tidb-cloud/releases/_index`
+
+**Target Pattern**: `/{curLang}/releases/tidb-cloud`
+
+**Example**:
+- Link: `/tidb-cloud/releases/_index`
+- Current Page: Any page
+- Result: `/releases/tidb-cloud`
+
+**Use Case**: Direct links to TiDB Cloud releases page.
+
+---
+
+### Rule 3: TiDB-in-Kubernetes Releases Index Links (Path-Based)
+
+**Effect**: Resolves `/tidb-in-kubernetes/releases/_index` links from TiDB-in-Kubernetes pages.
+
+**Path Pattern**: `/{lang}/tidb-in-kubernetes/{branch}/{...any}`
+
+**Link Pattern**: `/tidb-in-kubernetes/releases/_index`
+
+**Target Pattern**: `/{curLang}/releases/tidb-operator`
+
+**Example**:
+- Current Page: `/tidb-in-kubernetes/stable/deploy`
+- Link: `/tidb-in-kubernetes/releases/_index`
+- Result: `/releases/tidb-operator`
+
+**Use Case**: Links to TiDB-in-Kubernetes releases from operator pages.
+
+---
+
+### Rule 4: Namespace Links (Direct Mapping)
+
+**Effect**: Resolves namespace links (`develop`, `best-practices`, `api`, `ai`, `tidb-cloud`) to namespace URLs (published as `/developer`, `/best-practices`, `/api`, `/ai`, `/tidbcloud`).
+
+**Link Pattern**: `/{namespace}/{...any}/{docname}`
+
+**Target Pattern**: `/{curLang}/{namespace}/{docname}`
+
+**Conditions**: `namespace = ["tidb-cloud", "develop", "best-practices", "api", "ai"]`
+
+**Namespace Transform**:
+- `tidb-cloud` → `tidbcloud`
+- `develop` → `developer`
+
+**Example**:
+- Link: `/develop/vector-search`
+- Current Page: Any page
+- Result: `/developer/vector-search`
+- Link: `/tidb-cloud/releases/_index`
+- Current Page: Any page
+- Result: `/tidbcloud/releases/_index` (namespace transformed)
+
+**Use Case**: Direct links to namespace pages from any location.
+
+---
+
+### Rule 5: TiDBCloud Page Links (Path-Based)
+
+**Effect**: Resolves relative links from TiDBCloud pages to TiDBCloud URLs.
+
+**Path Pattern**: `/{lang}/tidbcloud/{...any}`
+
+**Link Pattern**: `/{...any}/{docname}`
+
+**Target Pattern**: `/{lang}/tidbcloud/{docname}`
+
+**Example**:
+- Current Page: `/tidbcloud/dedicated`
+- Link: `/getting-started`
+- Result: `/tidbcloud/getting-started`
+- Current Page: `/tidbcloud/dedicated/starter`
+- Link: `/quick-start`
+- Result: `/tidbcloud/quick-start`
+
+**Use Case**: Relative links within TiDBCloud documentation preserve the TiDBCloud context.
+
+---
+
+### Rule 6: Developer/Best-Practices/API/AI Namespace Page Links (Path-Based)
+
+**Effect**: Resolves relative links from namespace pages to TiDB stable branch URLs.
+
+**Path Pattern**: `/{lang}/{namespace}/{...any}`
+
+**Path Conditions**: `namespace = ["developer", "best-practices", "api", "ai"]`
+
+**Link Pattern**: `/{...any}/{docname}`
+
+**Target Pattern**: `/{lang}/tidb/stable/{docname}`
+
+**Example**:
+- Current Page: `/developer/overview`
+- Link: `/vector-search`
+- Result: `/tidb/stable/vector-search`
+- Current Page: `/best-practices/guide`
+- Link: `/performance-tuning`
+- Result: `/tidb/stable/performance-tuning`
+
+**Use Case**: Links from namespace pages resolve to TiDB stable branch, maintaining namespace context.
+
+---
+
+### Rule 7: TiDB/TiDB-in-Kubernetes Page Links (Path-Based)
+
+**Effect**: Resolves relative links from TiDB or TiDB-in-Kubernetes pages, preserving branch/version.
+
+**Path Pattern**: `/{lang}/{repo}/{branch}/{...any}`
+
+**Path Conditions**: `repo = ["tidb", "tidb-in-kubernetes"]`
+
+**Link Pattern**: `/{...any}/{docname}`
+
+**Target Pattern**: `/{lang}/{repo}/{branch}/{docname}`
+
+**Example**:
+- Current Page: `/tidb/stable/upgrade`
+- Link: `/upgrade-tidb-using-tiup`
+- Result: `/tidb/stable/upgrade-tidb-using-tiup`
+- Current Page: `/tidb/v8.5/alert-rules`
+- Link: `/monitoring`
+- Result: `/tidb/v8.5/monitoring`
+- Current Page: `/tidb-in-kubernetes/stable/deploy`
+- Link: `/deploy-tidb-on-kubernetes`
+- Result: `/tidb-in-kubernetes/stable/deploy-tidb-on-kubernetes`
+
+**Use Case**: Relative links within TiDB or TiDB-in-Kubernetes documentation preserve the branch/version context.
+
+---
+
+## Summary
+
+The URL mapping system provides:
+
+1. **Consistent URL Structure**: Source files are mapped to clean, SEO-friendly URLs
+2. **Context-Aware Link Resolution**: Links are resolved based on the current page's context
+3. **Namespace Support**: Special namespaces (`developer`, `best-practices`, `api`, `ai`) have their own URL structure
+4. **Branch Aliasing**: Internal branch names are transformed to user-friendly versions
+5. **Default Language Omission**: Default language (`en`) is omitted from URLs for cleaner paths
+6. **TOC-Driven Build**: Only files referenced in TOC files are built, reducing build size
+
+The system is designed to be maintainable and extensible, with configuration-driven rules that can be easily modified or extended as the documentation structure evolves.
diff --git a/gatsby/cloud-plan.ts b/gatsby/cloud-plan.ts
index fd2e9c49e..4e5192620 100644
--- a/gatsby/cloud-plan.ts
+++ b/gatsby/cloud-plan.ts
@@ -1,7 +1,8 @@
import { mdxAstToToc, TocQueryData } from "./toc";
import { generateConfig } from "./path";
import { extractFilesFromToc } from "./toc-filter";
-import { CloudPlan } from "shared/interface";
+import { CloudPlan } from "../src/shared/interface";
+import { calculateFileUrl } from "./url-resolver";
type TocMap = Map<
string,
@@ -46,7 +47,8 @@ export async function getTidbCloudFilesFromTocs(graphql: any): Promise {
tocNodes.forEach((node: TocQueryData["allMdx"]["nodes"][0]) => {
const { config } = generateConfig(node.slug);
- const toc = mdxAstToToc(node.mdxAST.children, config);
+ const tocPath = calculateFileUrl(node.slug);
+ const toc = mdxAstToToc(node.mdxAST.children, tocPath || node.slug);
const files = extractFilesFromToc(toc);
// Create a key for this specific locale/repo/version combination
@@ -59,13 +61,13 @@ export async function getTidbCloudFilesFromTocs(graphql: any): Promise {
let tocType: CloudPlan | null = null;
if (relativePath.includes("TOC.md")) {
- tocType = "dedicated";
+ tocType = CloudPlan.Dedicated;
} else if (relativePath.includes("TOC-tidb-cloud-starter")) {
- tocType = "starter";
+ tocType = CloudPlan.Starter;
} else if (relativePath.includes("TOC-tidb-cloud-essential")) {
- tocType = "essential";
+ tocType = CloudPlan.Essential;
} else if (relativePath.includes("TOC-tidb-cloud-premium")) {
- tocType = "premium";
+ tocType = CloudPlan.Premium;
}
// Initialize the entry if it doesn't exist
@@ -118,12 +120,12 @@ export function determineInDefaultPlan(
// Check if article is in TOC.md (dedicated)
if (dedicated.has(fileName)) {
- return "dedicated";
+ return CloudPlan.Dedicated;
}
// Check if article is in TOC-tidb-cloud-starter.md but not in TOC.md
if (starter.has(fileName) && !dedicated.has(fileName)) {
- return "starter";
+ return CloudPlan.Starter;
}
// Check if article is only in TOC-tidb-cloud-essential.md
@@ -132,7 +134,7 @@ export function determineInDefaultPlan(
!dedicated.has(fileName) &&
!starter.has(fileName)
) {
- return "essential";
+ return CloudPlan.Essential;
}
if (
@@ -141,7 +143,7 @@ export function determineInDefaultPlan(
!dedicated.has(fileName) &&
!starter.has(fileName)
) {
- return "premium";
+ return CloudPlan.Premium;
}
return null;
diff --git a/gatsby/create-pages/create-doc-home.ts b/gatsby/create-pages/create-doc-home.ts
index 76f5618c8..751d22a4c 100644
--- a/gatsby/create-pages/create-doc-home.ts
+++ b/gatsby/create-pages/create-doc-home.ts
@@ -3,13 +3,16 @@ import { resolve } from "path";
import type { CreatePagesArgs } from "gatsby";
import sig from "signale";
-import { Locale, BuildType } from "../../src/shared/interface";
+import {
+ Locale,
+ BuildType,
+ TOCNamespace,
+ TOCNamespaceSlugMap,
+} from "../../src/shared/interface";
import {
generateConfig,
- generateNav,
+ generateNavTOCPath,
generateDocHomeUrl,
- generateStarterNav,
- generateEssentialNav,
} from "../../gatsby/path";
import { DEFAULT_BUILD_TYPE, PageQueryData } from "./interface";
@@ -86,9 +89,14 @@ export const createDocHome = async ({
nodes.forEach((node) => {
const { id, name, pathConfig, filePath, slug } = node;
const path = generateDocHomeUrl(name, pathConfig);
- const navUrl = generateNav(pathConfig);
- const starterNavUrl = generateStarterNav(pathConfig);
- const essentialNavUrl = generateEssentialNav(pathConfig);
+ const namespace = TOCNamespace.Home;
+ const namespaceSlug = TOCNamespaceSlugMap[namespace];
+ const navUrl = generateNavTOCPath(pathConfig, namespaceSlug);
+ const starterNavUrl = generateNavTOCPath(pathConfig, "tidb-cloud-starter");
+ const essentialNavUrl = generateNavTOCPath(
+ pathConfig,
+ "tidb-cloud-essential"
+ );
const locale =
process.env.WEBSITE_BUILD_TYPE === "archive"
? [Locale.en, Locale.zh]
@@ -118,6 +126,7 @@ export const createDocHome = async ({
feedback: true,
globalHome: true,
},
+ namespace,
},
});
});
diff --git a/gatsby/create-pages/create-docs.ts b/gatsby/create-pages/create-docs.ts
index ca266fbf9..4f2fb1da7 100644
--- a/gatsby/create-pages/create-docs.ts
+++ b/gatsby/create-pages/create-docs.ts
@@ -3,14 +3,19 @@ import { resolve } from "path";
import type { CreatePagesArgs } from "gatsby";
import sig from "signale";
-import { Locale, Repo, BuildType } from "../../src/shared/interface";
+import {
+ Locale,
+ Repo,
+ BuildType,
+ TOCNamespaceSlugMap,
+ TOCNamespace,
+} from "../../src/shared/interface";
import {
generateConfig,
- generateUrl,
- generateNav,
- generateStarterNav,
- generateEssentialNav,
+ generateNavTOCPath,
+ getTOCNamespace,
} from "../../gatsby/path";
+import { calculateFileUrl } from "../../gatsby/url-resolver";
import { cpMarkdown } from "../../gatsby/cp-markdown";
import {
getTidbCloudFilesFromTocs,
@@ -112,10 +117,22 @@ export const createDocs = async (createPagesArgs: CreatePagesArgs) => {
return;
}
- const path = generateUrl(name, pathConfig);
- const navUrl = generateNav(pathConfig);
- const starterNavUrl = generateStarterNav(pathConfig);
- const essentialNavUrl = generateEssentialNav(pathConfig);
+ const path = calculateFileUrl(node.slug, true);
+ if (!path) {
+ console.info(
+ `Failed to calculate URL for ${node.slug}, filePath: ${filePath}`
+ );
+ return;
+ }
+
+ const namespace = getTOCNamespace(node.slug);
+ const namespaceSlug = TOCNamespaceSlugMap[namespace || TOCNamespace.TiDB];
+ const navUrl = generateNavTOCPath(pathConfig, namespaceSlug);
+ const starterNavUrl = generateNavTOCPath(pathConfig, "tidb-cloud-starter");
+ const essentialNavUrl = generateNavTOCPath(
+ pathConfig,
+ "tidb-cloud-essential"
+ );
const locale = [Locale.en, Locale.zh, Locale.ja]
.map((l) =>
@@ -157,6 +174,7 @@ export const createDocs = async (createPagesArgs: CreatePagesArgs) => {
feedback: true,
},
inDefaultPlan,
+ namespace,
},
});
diff --git a/gatsby/create-pages/interface.ts b/gatsby/create-pages/interface.ts
index a87d27237..8f5be2334 100644
--- a/gatsby/create-pages/interface.ts
+++ b/gatsby/create-pages/interface.ts
@@ -8,6 +8,10 @@ export interface PageQueryData {
id: string;
frontmatter: { aliases: string[] };
slug: string;
+ parent: {
+ fileAbsolutePath: string;
+ relativePath: string;
+ } | null;
}[];
};
}
diff --git a/gatsby/create-types/create-frontmatter.ts b/gatsby/create-types/create-frontmatter.ts
new file mode 100644
index 000000000..993e76299
--- /dev/null
+++ b/gatsby/create-types/create-frontmatter.ts
@@ -0,0 +1,29 @@
+import { CreatePagesArgs } from "gatsby";
+
+export const createFrontmatter = ({ actions }: CreatePagesArgs) => {
+ const { createTypes } = actions;
+
+ const typeDefs = `
+ """
+ Markdown Node
+ """
+ type Mdx implements Node @dontInfer {
+ frontmatter: Frontmatter
+ }
+
+ """
+ Markdown Frontmatter
+ """
+ type Frontmatter {
+ title: String!
+ summary: String
+ aliases: [String!]
+ draft: Boolean
+ hide_sidebar: Boolean
+ hide_commit: Boolean
+ hide_leftNav: Boolean
+ }
+ `;
+
+ createTypes(typeDefs);
+};
diff --git a/gatsby/create-types.ts b/gatsby/create-types/create-navs.ts
similarity index 72%
rename from gatsby/create-types.ts
rename to gatsby/create-types/create-navs.ts
index b2c6e4053..5fe431d74 100644
--- a/gatsby/create-types.ts
+++ b/gatsby/create-types/create-navs.ts
@@ -1,35 +1,11 @@
import { CreatePagesArgs } from "gatsby";
-import { generateConfig } from "./path";
-import { mdxAstToToc } from "./toc";
-import { Root, List } from "mdast";
+import { mdxAstToToc } from "../toc";
+import { Root } from "mdast";
+import { calculateFileUrl } from "../url-resolver";
-export const createExtraType = ({ actions }: CreatePagesArgs) => {
+export const createNavs = ({ actions }: CreatePagesArgs) => {
const { createTypes, createFieldExtension } = actions;
- const typeDefs = `
- """
- Markdown Node
- """
- type Mdx implements Node @dontInfer {
- frontmatter: Frontmatter
- }
-
- """
- Markdown Frontmatter
- """
- type Frontmatter {
- title: String!
- summary: String
- aliases: [String!]
- draft: Boolean
- hide_sidebar: Boolean
- hide_commit: Boolean
- hide_leftNav: Boolean
- }
- `;
-
- createTypes(typeDefs);
-
createFieldExtension({
name: "navigation",
extend() {
@@ -55,10 +31,13 @@ export const createExtraType = ({ actions }: CreatePagesArgs) => {
}
);
- if (!slug.endsWith("TOC"))
- throw new Error(`unsupported query in ${slug}`);
- const { config } = generateConfig(slug);
- const res = mdxAstToToc(mdxAST.children, config, undefined, true);
+ const tocPath = calculateFileUrl(slug);
+ const res = mdxAstToToc(
+ mdxAST.children,
+ tocPath || slug,
+ undefined,
+ true
+ );
mdxNode.nav = res;
return res;
},
@@ -93,8 +72,13 @@ export const createExtraType = ({ actions }: CreatePagesArgs) => {
if (!slug.endsWith("TOC-tidb-cloud-starter"))
throw new Error(`unsupported query in ${slug}`);
- const { config } = generateConfig(slug);
- const res = mdxAstToToc(mdxAST.children, config, undefined, true);
+ const tocPath = calculateFileUrl(slug);
+ const res = mdxAstToToc(
+ mdxAST.children,
+ tocPath || slug,
+ undefined,
+ true
+ );
mdxNode.starterNav = res;
return res;
},
@@ -129,8 +113,13 @@ export const createExtraType = ({ actions }: CreatePagesArgs) => {
if (!slug.endsWith("TOC-tidb-cloud-essential"))
throw new Error(`unsupported query in ${slug}`);
- const { config } = generateConfig(slug);
- const res = mdxAstToToc(mdxAST.children, config, undefined, true);
+ const tocPath = calculateFileUrl(slug);
+ const res = mdxAstToToc(
+ mdxAST.children,
+ tocPath || slug,
+ undefined,
+ true
+ );
mdxNode.essentialNav = res;
return res;
},
diff --git a/gatsby/create-types/index.ts b/gatsby/create-types/index.ts
new file mode 100644
index 000000000..c31f8a93c
--- /dev/null
+++ b/gatsby/create-types/index.ts
@@ -0,0 +1,2 @@
+export * from "./create-frontmatter";
+export * from "./create-navs";
diff --git a/gatsby/link-resolver/README.md b/gatsby/link-resolver/README.md
new file mode 100644
index 000000000..cecfcf967
--- /dev/null
+++ b/gatsby/link-resolver/README.md
@@ -0,0 +1,445 @@
+# Link Resolver
+
+## Overview
+
+The Link Resolver is a module that transforms internal markdown links within articles based on the current page's context. It provides context-aware link resolution, allowing relative links in markdown files to be automatically converted to correct absolute URLs.
+
+## Purpose
+
+The Link Resolver serves the following purposes:
+
+1. **Context-Aware Resolution**: Resolves relative links based on the current page's URL, maintaining proper navigation structure
+2. **Namespace Handling**: Handles special namespaces (`develop`, `best-practices`, `api`, `tidb-cloud`) with custom transformation rules (`develop` links are mapped to `developer`)
+3. **Language Preservation**: Automatically preserves the current page's language in resolved links
+4. **Path-Based Mapping**: Supports both direct link mappings and path-based mappings (where rules depend on the current page's path)
+5. **Default Language Omission**: Optionally omits the default language prefix from resolved URLs
+
+## Usage
+
+### Basic Usage
+
+```typescript
+import { resolveMarkdownLink } from "../../gatsby/link-resolver";
+
+// Resolve a link from a tidbcloud page
+const resolved = resolveMarkdownLink(
+ "/dedicated/getting-started", // link path
+ "/tidbcloud/dedicated" // current page URL
+);
+// Result: "/tidbcloud/getting-started"
+
+// Resolve a legacy namespace link (/develop -> /developer)
+const resolved2 = resolveMarkdownLink(
+ "/develop/vector-search",
+ "/en/tidb/stable/overview"
+);
+// Result: "/developer/vector-search"
+
+// Resolve a link from a tidb page
+const resolved3 = resolveMarkdownLink(
+ "/upgrade/upgrade-tidb-using-tiup",
+ "/tidb/stable/overview"
+);
+// Result: "/tidb/stable/upgrade-tidb-using-tiup"
+```
+
+### Usage in Gatsby
+
+The Link Resolver is used in `gatsby/plugin/content/index.ts` during markdown processing:
+
+```typescript
+import { calculateFileUrl } from "../../url-resolver";
+import { resolveMarkdownLink } from "../../link-resolver";
+
+module.exports = function ({ markdownAST, markdownNode }) {
+ // Get current page URL from file path
+ const currentFileUrl = calculateFileUrl(markdownNode.fileAbsolutePath) || "";
+
+ visit(markdownAST, (node: any) => {
+ if (node.type === "link" && !node.url.startsWith("#")) {
+ const ele = node as Link;
+
+ if (ele.url.startsWith("http")) {
+ // External links: keep as-is
+ return externalLinkNode;
+ } else {
+ // Internal links: resolve using link-resolver
+ const resolvedPath = resolveMarkdownLink(
+ ele.url.replace(".md", ""), // Remove .md extension
+ currentFileUrl
+ );
+
+ return {
+ type: "jsx",
+ value: ``,
+ // ... children
+ };
+ }
+ }
+ });
+};
+```
+
+## Configuration
+
+### Configuration Structure
+
+The Link Resolver configuration is defined in `gatsby/link-resolver/config.ts`:
+
+```typescript
+export const defaultLinkResolverConfig: LinkResolverConfig = {
+ defaultLanguage: "en",
+
+ linkMappings: [
+ // Direct link mapping (no pathPattern)
+ {
+ linkPattern: "/{namespace}/{...any}/{docname}",
+ targetPattern: "/{curLang}/{namespace}/{docname}",
+ conditions: {
+ namespace: ["tidb-cloud", "develop", "best-practices", "api"],
+ },
+ namespaceTransform: {
+ "tidb-cloud": "tidbcloud",
+ develop: "developer",
+ },
+ },
+
+ // Path-based mapping (with pathPattern)
+ {
+ pathPattern: "/{lang}/tidbcloud/{...any}",
+ linkPattern: "/{...any}/{docname}",
+ targetPattern: "/{lang}/tidbcloud/{docname}",
+ },
+ // ... more rules
+ ],
+};
+```
+
+### `LinkResolverConfig` Options
+
+- `linkMappings` (`LinkMappingRule[]`): Ordered link resolution rules (first match wins). Each rule can be either:
+ - **Direct mapping**: `linkPattern` + `targetPattern`
+ - **Path-based mapping**: `pathPattern` + `linkPattern` + `targetPattern`
+- `defaultLanguage` (`string`, optional): If the resolved URL starts with `/{defaultLanguage}/`, the language prefix is removed (for example, `/en/tidb/stable/...` -> `/tidb/stable/...`). If omitted, it falls back to the `url-resolver` `defaultLanguage`.
+
+#### `LinkMappingRule` Fields
+
+- `pathPattern` (`string`, optional): Pattern to match the current page URL (enables path-based mapping and provides context variables like `{lang}`, `{repo}`, `{branch}`).
+- `linkPattern` (`string`): Pattern to match the markdown link path (the input link is normalized to start with `/` before matching).
+- `targetPattern` (`string`): Pattern to generate the resolved URL. Can reference extracted variables and `{curLang}` (current page language).
+- `conditions` (`Record`, optional): Restricts when the rule applies by allowing only specific values for extracted variables.
+- `pathConditions` (`Record`, optional): Like `conditions`, but checked against variables extracted from `pathPattern` (only for path-based mappings).
+- `namespaceTransform` (`Record`, optional): Transforms the `{namespace}` variable before applying `targetPattern` (for example, `develop -> developer`, `tidb-cloud -> tidbcloud`).
+
+### Pattern Syntax
+
+#### Variables
+
+- `{curLang}` - Current page's language (extracted from current page URL)
+- `{lang}` - Language from current page or link path
+- `{repo}` - Repository name (e.g., `tidb`, `tidbcloud`)
+- `{branch}` - Branch name (e.g., `stable`, `v8.5`)
+- `{namespace}` - Namespace (e.g., `develop`, `best-practices`, `api`)
+- `{docname}` - Document name (filename without extension)
+- `{...any}` - Variable number of path segments
+
+### Rule Types
+
+#### 1. Direct Link Mapping
+
+Direct link mappings match links directly without considering the current page path:
+
+```typescript
+{
+ linkPattern: "/{namespace}/{...any}/{docname}",
+ targetPattern: "/{curLang}/{namespace}/{docname}",
+ conditions: {
+ namespace: ["develop", "best-practices", "api"],
+ },
+}
+```
+
+**Example**:
+- Link: `/develop/vector-search`
+- Current Page: `/en/tidb/stable/overview` (any page)
+- Result: `/developer/vector-search` (or `/zh/developer/vector-search` depending on current page language)
+
+#### 2. Path-Based Mapping
+
+Path-based mappings first match the current page path, then match the link path:
+
+```typescript
+{
+ pathPattern: "/{lang}/tidbcloud/{...any}",
+ linkPattern: "/{...any}/{docname}",
+ targetPattern: "/{lang}/tidbcloud/{docname}",
+}
+```
+
+**Example**:
+- Current Page: `/tidbcloud/dedicated`
+- Link: `/getting-started`
+- Result: `/tidbcloud/getting-started`
+
+**How it works**:
+1. Match `pathPattern` against current page: `/{lang}/tidbcloud/{...any}` matches `/tidbcloud/dedicated`
+ - Variables: `{ lang: "en" (default), ...any: ["dedicated"] }`
+2. Match `linkPattern` against link: `/{...any}/{docname}` matches `/getting-started`
+ - Variables: `{ ...any: [], docname: "getting-started" }`
+3. Merge variables and apply `targetPattern`: `/{lang}/tidbcloud/{docname}`
+ - Result: `/tidbcloud/getting-started`
+
+### Conditions
+
+#### `conditions`
+
+Conditions are checked against link variables (for direct mappings) or merged variables (for path-based mappings):
+
+```typescript
+{
+ linkPattern: "/{namespace}/{...any}/{docname}",
+ targetPattern: "/{curLang}/{namespace}/{docname}",
+ conditions: {
+ namespace: ["develop", "best-practices", "api"],
+ },
+}
+```
+
+#### `pathConditions`
+
+Path conditions are checked against variables extracted from the current page path (only for path-based mappings):
+
+```typescript
+{
+ pathPattern: "/{lang}/{repo}/{branch}/{...any}",
+ pathConditions: {
+ repo: ["tidb", "tidb-in-kubernetes"],
+ },
+ linkPattern: "/{...any}/{docname}",
+ targetPattern: "/{lang}/{repo}/{branch}/{docname}",
+}
+```
+
+### Namespace Transformation
+
+Transform namespace values during resolution:
+
+```typescript
+{
+ linkPattern: "/{namespace}/{...any}/{docname}",
+ targetPattern: "/{curLang}/{namespace}/{docname}",
+ namespaceTransform: {
+ "tidb-cloud": "tidbcloud",
+ },
+}
+```
+
+**Example**:
+- Link: `/tidb-cloud/releases/_index`
+- Result: `/tidbcloud/releases/_index` (namespace transformed)
+
+## API Reference
+
+### `resolveMarkdownLink(linkPath, currentPageUrl)`
+
+Resolves a markdown link path based on the current page URL.
+
+**Parameters**:
+- `linkPath`: The markdown link path to resolve (e.g., `"/develop/vector-search"` or `"develop/vector-search"`)
+- `currentPageUrl`: The current page URL for context-based resolution (e.g., `"/tidb/stable/overview"`)
+
+**Returns**: `string | null` - The resolved URL or the original link path if no rule matches
+
+**Behavior**:
+- Returns external links (`http://`, `https://`) as-is
+- Returns anchor links (`#section`) as-is
+- Returns empty links as-is
+- Normalizes link paths (adds leading slash if missing)
+- Processes rules in order (first match wins)
+- Caches results for performance
+
+### `clearLinkResolverCache()`
+
+Clears all caches (useful for testing or when configuration changes).
+
+## How It Works
+
+1. **Early Exit**: External links, anchor links, and empty links are returned as-is.
+
+2. **Path Normalization**: Link paths are normalized (leading slash added if missing).
+
+3. **Rule Processing**: Rules are processed in order:
+ - **Direct Link Mapping**: Match `linkPattern` against link path
+ - **Path-Based Mapping**:
+ - First match `pathPattern` against current page path
+ - Check `pathConditions` if specified
+ - Then match `linkPattern` against link path
+ - Merge variables from both matches
+
+4. **Variable Extraction**: Variables are extracted from matched patterns.
+
+5. **Condition Checking**: Conditions are checked against extracted variables.
+
+6. **Namespace Transformation**: If `namespaceTransform` is specified, transform namespace values.
+
+7. **curLang Injection**: The current page's language (`curLang`) is automatically extracted and added to variables.
+
+8. **URL Construction**: The target URL is built by applying the target pattern with variables.
+
+9. **Post-processing**:
+ - Default language omission (if `defaultLanguage` is set)
+ - Trailing slash removal (if `trailingSlash: "never"`)
+
+10. **Fallback**: If no rule matches, return the original link path.
+
+## Examples
+
+### Example 1: Namespace Link from Any Page
+
+```typescript
+// Link: "/develop/vector-search"
+// Current Page: "/en/tidb/stable/overview" (any page)
+// Rule: Direct link mapping
+// linkPattern: "/{namespace}/{...any}/{docname}"
+// Matches: namespace="develop", docname="vector-search"
+// targetPattern: "/{curLang}/{namespace}/{docname}"
+// curLang extracted from current page: "en"
+// namespaceTransform: "develop" -> "developer"
+// Result: "/developer/vector-search" (default language omitted)
+```
+
+### Example 2: TiDBCloud Page Link
+
+```typescript
+// Current Page: "/tidbcloud/dedicated"
+// Link: "/getting-started"
+// Rule: Path-based mapping
+// pathPattern: "/{lang}/tidbcloud/{...any}"
+// Matches current page: lang="en" (default), ...any=["dedicated"]
+// linkPattern: "/{...any}/{docname}"
+// Matches link: ...any=[], docname="getting-started"
+// targetPattern: "/{lang}/tidbcloud/{docname}"
+// Result: "/tidbcloud/getting-started"
+```
+
+### Example 3: TiDB Page with Branch
+
+```typescript
+// Current Page: "/tidb/stable/upgrade"
+// Link: "/upgrade-tidb-using-tiup"
+// Rule: Path-based mapping
+// pathPattern: "/{lang}/{repo}/{branch}/{...any}"
+// pathConditions: repo=["tidb", "tidb-in-kubernetes"]
+// Matches current page: lang="en", repo="tidb", branch="stable", ...any=["upgrade"]
+// linkPattern: "/{...any}/{docname}"
+// Matches link: ...any=[], docname="upgrade-tidb-using-tiup"
+// targetPattern: "/{lang}/{repo}/{branch}/{docname}"
+// Result: "/tidb/stable/upgrade-tidb-using-tiup"
+```
+
+### Example 4: Namespace Transformation
+
+```typescript
+// Link: "/tidb-cloud/releases/_index"
+// Current Page: "/en/tidb/stable/overview" (any page)
+// Rule: Direct link mapping with namespace transform
+// linkPattern: "/{namespace}/{...any}/{docname}"
+// Matches: namespace="tidb-cloud", docname="_index"
+// namespaceTransform: "tidb-cloud" -> "tidbcloud"
+// targetPattern: "/{curLang}/{namespace}/{docname}"
+// Result: "/tidbcloud/releases/_index"
+```
+
+### Example 5: Hash Preservation
+
+```typescript
+// Link: "/develop/vector-search#data-types"
+// Current Page: "/en/tidb/stable/overview"
+// Rule: Direct link mapping
+// Hash is preserved automatically
+// Result: "/developer/vector-search#data-types"
+```
+
+## Common Patterns
+
+### Pattern 1: Namespace Links
+
+Links starting with `develop`, `best-practices`, `api`, or `tidb-cloud` are resolved to namespace URLs (`develop` is mapped to `/developer`):
+
+```markdown
+
+[Vector Search](/develop/vector-search)
+
+```
+
+### Pattern 2: Relative Links from TiDBCloud Pages
+
+Relative links from TiDBCloud pages are resolved to TiDBCloud URLs:
+
+```markdown
+
+[Getting Started](/getting-started)
+
+```
+
+### Pattern 3: Relative Links from TiDB Pages
+
+Relative links from TiDB pages preserve the branch:
+
+```markdown
+
+[Upgrade Guide](/upgrade-tidb-using-tiup)
+
+```
+
+### Pattern 4: Releases Links
+
+Special handling for releases index pages:
+
+```markdown
+
+[TiDB Releases](/releases/_index)
+
+
+[TiDB Cloud Releases](/tidb-cloud/releases/_index)
+
+```
+
+## Best Practices
+
+1. **Rule Order**: Place more specific rules before general ones, as the first match wins.
+
+2. **Path Conditions**: Use `pathConditions` to narrow down when path-based rules should apply.
+
+3. **curLang Variable**: Always use `{curLang}` in target patterns to preserve the current page's language.
+
+4. **Namespace Transforms**: Use `namespaceTransform` to handle namespace name differences (e.g., `tidb-cloud` vs `tidbcloud`).
+
+5. **Hash Preservation**: The resolver automatically preserves hash fragments (`#section`) in links.
+
+6. **Testing**: Test link resolution from various page contexts to ensure correct behavior.
+
+## Troubleshooting
+
+### Link Not Resolved
+
+- Check if the link matches any `linkPattern` in the configuration
+- For path-based mappings, verify the current page matches the `pathPattern`
+- Check if conditions are correctly specified
+- Ensure the link path format is correct (with or without leading slash)
+
+### Wrong URL Generated
+
+- Check rule order - earlier rules take precedence
+- Verify path conditions are correctly specified
+- Check if namespace transforms are applied correctly
+- Ensure `curLang` is being extracted correctly from the current page
+
+### Language Not Preserved
+
+- Ensure `{curLang}` is used in the target pattern
+- Verify the current page URL has a language prefix (or defaults to configured default language)
+
+### Hash Fragment Lost
+
+- Hash fragments are automatically preserved - if lost, check if the link is being processed elsewhere
diff --git a/gatsby/link-resolver/__tests__/link-resolver.test.ts b/gatsby/link-resolver/__tests__/link-resolver.test.ts
new file mode 100644
index 000000000..23f332132
--- /dev/null
+++ b/gatsby/link-resolver/__tests__/link-resolver.test.ts
@@ -0,0 +1,786 @@
+/**
+ * Tests for link-resolver.ts
+ */
+
+import { resolveMarkdownLink } from "../link-resolver";
+
+describe("resolveMarkdownLink", () => {
+ describe("External links and anchor links", () => {
+ it("should return external http links as-is", () => {
+ const result = resolveMarkdownLink(
+ "http://example.com/page",
+ "/en/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("http://example.com/page");
+ });
+
+ it("should return external https links as-is", () => {
+ const result = resolveMarkdownLink(
+ "https://example.com/page",
+ "/en/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("https://example.com/page");
+ });
+
+ it("should return anchor links as-is", () => {
+ const result = resolveMarkdownLink(
+ "#section",
+ "/en/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("#section");
+ });
+
+ it("should return empty link as-is", () => {
+ const result = resolveMarkdownLink("", "/en/tidb/stable/alert-rules");
+ expect(result).toBe("");
+ });
+ });
+
+ describe("Links with hash (anchor)", () => {
+ it("should preserve hash for namespace links", () => {
+ const result = resolveMarkdownLink(
+ "/develop/vector-search#data-types",
+ "/en/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/developer/vector-search#data-types");
+ });
+
+ it("should preserve hash for tidbcloud links", () => {
+ const result = resolveMarkdownLink(
+ "/dedicated/getting-started#quick-start",
+ "/en/tidbcloud/dedicated"
+ );
+ expect(result).toBe("/tidbcloud/getting-started#quick-start");
+ });
+
+ it("should preserve hash for tidb links", () => {
+ const result = resolveMarkdownLink(
+ "/upgrade/upgrade-tidb-using-tiup#prerequisites",
+ "/en/tidb/stable/upgrade"
+ );
+ expect(result).toBe("/tidb/stable/upgrade-tidb-using-tiup#prerequisites");
+ });
+
+ it("should preserve hash for links that don't match any rule", () => {
+ const result = resolveMarkdownLink(
+ "/some/path/to/page#section",
+ "/en/tidb/some-path"
+ );
+ // /en/tidb/some-path matches Rule 3 /{lang}/{repo}/{branch}/{...any} where branch=some-path, {...any}=""
+ // Link /some/path/to/page matches /{...any}/{docname} where {...any}=some/path/to, docname=page
+ // Target: /{lang}/{repo}/{branch}/{docname} = /en/tidb/some-path/page
+ // After defaultLanguage omission: /tidb/some-path/page
+ expect(result).toBe("/tidb/some-path/page#section");
+ });
+
+ it("should preserve hash with multiple segments", () => {
+ const result = resolveMarkdownLink(
+ "/develop/a/b/c/page#anchor-name",
+ "/en/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/developer/page#anchor-name");
+ });
+
+ it("should preserve hash for links without leading slash", () => {
+ const result = resolveMarkdownLink(
+ "develop/vector-search#section",
+ "/en/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/developer/vector-search#section");
+ });
+
+ it("should preserve hash for tidb-in-kubernetes links", () => {
+ const result = resolveMarkdownLink(
+ "/deploy/deploy-tidb-on-kubernetes#configuration",
+ "/en/tidb-in-kubernetes/stable/deploy"
+ );
+ expect(result).toBe(
+ "/tidb-in-kubernetes/stable/deploy-tidb-on-kubernetes#configuration"
+ );
+ });
+
+ it("should preserve hash for Chinese links", () => {
+ const result = resolveMarkdownLink(
+ "/dedicated/getting-started#快速开始",
+ "/zh/tidbcloud/dedicated"
+ );
+ expect(result).toBe("/zh/tidbcloud/getting-started#快速开始");
+ });
+
+ it("should preserve hash for links that don't match any rule", () => {
+ const result = resolveMarkdownLink(
+ "/unknown/path#anchor",
+ "/en/tidb/stable/alert-rules"
+ );
+ // Link doesn't match linkMappings, but matches linkMappingsByPath
+ expect(result).toBe("/tidb/stable/path#anchor");
+ });
+
+ it("should handle hash with special characters", () => {
+ const result = resolveMarkdownLink(
+ "/develop/page#section-1_2-3",
+ "/en/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/developer/page#section-1_2-3");
+ });
+ });
+
+ describe("linkMappings - namespace rules", () => {
+ it("should resolve develop namespace links (en - default language omitted)", () => {
+ const result = resolveMarkdownLink(
+ "/develop/vector-search/vector-search-data-types",
+ "/en/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/developer/vector-search-data-types");
+ });
+
+ it("should resolve develop namespace links (zh - language prefix included)", () => {
+ const result = resolveMarkdownLink(
+ "/develop/vector-search/vector-search-data-types",
+ "/zh/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/zh/developer/vector-search-data-types");
+ });
+
+ it("should resolve develop namespace links (ja - language prefix included)", () => {
+ const result = resolveMarkdownLink(
+ "/develop/vector-search/vector-search-data-types",
+ "/ja/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/ja/developer/vector-search-data-types");
+ });
+
+ it("should resolve best-practices namespace links (en - default language omitted)", () => {
+ const result = resolveMarkdownLink(
+ "/best-practices/optimization/query-optimization",
+ "/en/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/best-practices/query-optimization");
+ });
+
+ it("should resolve best-practices namespace links (zh - language prefix included)", () => {
+ const result = resolveMarkdownLink(
+ "/best-practices/optimization/query-optimization",
+ "/zh/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/zh/best-practices/query-optimization");
+ });
+
+ it("should resolve api namespace links (en - default language omitted)", () => {
+ const result = resolveMarkdownLink(
+ "/api/overview/introduction",
+ "/en/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/api/introduction");
+ });
+
+ it("should resolve api namespace links (zh - language prefix included)", () => {
+ const result = resolveMarkdownLink(
+ "/api/overview/introduction",
+ "/zh/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/zh/api/introduction");
+ });
+
+ it("should resolve ai namespace links (en - default language omitted)", () => {
+ const result = resolveMarkdownLink(
+ "/ai/overview/introduction",
+ "/en/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/ai/introduction");
+ });
+
+ it("should resolve ai namespace links (zh - language prefix included)", () => {
+ const result = resolveMarkdownLink(
+ "/ai/overview/introduction",
+ "/zh/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/zh/ai/introduction");
+ });
+
+ it("should resolve releases/_index links to tidb-self-managed (en - default language omitted)", () => {
+ const result = resolveMarkdownLink(
+ "/releases/_index",
+ "/en/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/releases/tidb-self-managed");
+ });
+
+ it("should resolve releases/_index links to tidb-self-managed (zh - language prefix included)", () => {
+ const result = resolveMarkdownLink(
+ "/releases/_index",
+ "/zh/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/zh/releases/tidb-self-managed");
+ });
+
+ it("should resolve tidb-cloud/releases/_index links (en - default language omitted)", () => {
+ const result = resolveMarkdownLink(
+ "/tidb-cloud/releases/_index",
+ "/en/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/releases/tidb-cloud");
+ });
+
+ it("should resolve tidb-cloud/releases/_index links (zh - language prefix included)", () => {
+ const result = resolveMarkdownLink(
+ "/tidb-cloud/releases/_index",
+ "/zh/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/zh/releases/tidb-cloud");
+ });
+
+ it("should resolve tidb-in-kubernetes/releases/_index links from tidb-in-kubernetes pages (en - default language omitted)", () => {
+ const result = resolveMarkdownLink(
+ "/tidb-in-kubernetes/releases/_index",
+ "/en/tidb-in-kubernetes/stable/deploy"
+ );
+ expect(result).toBe("/releases/tidb-operator");
+ });
+
+ it("should resolve tidb-in-kubernetes/releases/_index links from tidb-in-kubernetes pages (zh - language prefix included)", () => {
+ const result = resolveMarkdownLink(
+ "/tidb-in-kubernetes/releases/_index",
+ "/zh/tidb-in-kubernetes/stable/deploy"
+ );
+ expect(result).toBe("/zh/releases/tidb-operator");
+ });
+
+ it("should resolve releases namespace links (en - matches Rule 4, not Rule 1)", () => {
+ const result = resolveMarkdownLink(
+ "/releases/v8.5/release-notes",
+ "/en/tidb/stable/alert-rules"
+ );
+ // /releases/v8.5/release-notes doesn't match Rule 1 (releases not in conditions)
+ // Matches Rule 4: current page /en/tidb/stable/alert-rules matches /{lang}/{repo}/{branch}/{...any}
+ // Link /releases/v8.5/release-notes matches /{...any}/{docname}
+ // Target: /{lang}/{repo}/{branch}/{docname} = /en/tidb/stable/release-notes
+ // After defaultLanguage omission: /tidb/stable/release-notes
+ expect(result).toBe("/tidb/stable/release-notes");
+ });
+
+ it("should resolve releases namespace links (zh - matches Rule 4, not Rule 1)", () => {
+ const result = resolveMarkdownLink(
+ "/releases/v8.5/release-notes",
+ "/zh/tidb/stable/alert-rules"
+ );
+ // Same as above but with zh language prefix
+ expect(result).toBe("/zh/tidb/stable/release-notes");
+ });
+
+ it("should transform tidb-cloud to tidbcloud (en - default language omitted)", () => {
+ const result = resolveMarkdownLink(
+ "/tidb-cloud/dedicated/getting-started",
+ "/en/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/tidbcloud/getting-started");
+ });
+
+ it("should transform tidb-cloud to tidbcloud (zh - language prefix included)", () => {
+ const result = resolveMarkdownLink(
+ "/tidb-cloud/dedicated/getting-started",
+ "/zh/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/zh/tidbcloud/getting-started");
+ });
+
+ it("should not match non-namespace links", () => {
+ const result = resolveMarkdownLink(
+ "/other/path/to/page",
+ "/en/tidb/stable/alert-rules"
+ );
+ // Link /other/path/to/page doesn't match linkMappings (not a namespace)
+ // But current page /en/tidb/stable/alert-rules matches /{lang}/{repo}/{branch}/{...any}
+ // Link matches /{...any}/{docname} where {...any} = other/path/to, docname = page
+ // Target pattern /{lang}/{repo}/{branch}/{docname} = /en/tidb/stable/page
+ // After defaultLanguage omission: /tidb/stable/page
+ expect(result).toBe("/tidb/stable/page");
+ });
+
+ it("should handle namespace links with multiple path segments (en)", () => {
+ const result = resolveMarkdownLink(
+ "/develop/a/b/c/d/e/page",
+ "/en/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/developer/page");
+ });
+
+ it("should handle namespace links with multiple path segments (zh)", () => {
+ const result = resolveMarkdownLink(
+ "/develop/a/b/c/d/e/page",
+ "/zh/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/zh/developer/page");
+ });
+ });
+
+ describe("linkMappingsByPath - tidbcloud pages", () => {
+ it("should resolve links from tidbcloud pages (with lang)", () => {
+ const result = resolveMarkdownLink(
+ "/dedicated/getting-started",
+ "/en/tidbcloud/dedicated"
+ );
+ expect(result).toBe("/tidbcloud/getting-started");
+ });
+
+ it("should resolve links from tidbcloud pages (without lang, default omitted)", () => {
+ const result = resolveMarkdownLink(
+ "/dedicated/getting-started",
+ "/tidbcloud/dedicated"
+ );
+ // Should not match because pathPattern requires /{lang}/tidbcloud
+ expect(result).toBe("/dedicated/getting-started");
+ });
+
+ it("should resolve links with multiple path segments from tidbcloud pages", () => {
+ const result = resolveMarkdownLink(
+ "/dedicated/setup/configuration",
+ "/en/tidbcloud/dedicated"
+ );
+ expect(result).toBe("/tidbcloud/configuration");
+ });
+
+ it("should resolve links from tidbcloud pages /tidbcloud", () => {
+ const result = resolveMarkdownLink(
+ "/vector-search/vector-search-data-types",
+ "/en/tidbcloud"
+ );
+ expect(result).toBe("/tidbcloud/vector-search-data-types");
+ });
+ });
+
+ describe("linkMappingsByPath - tidb pages with branch", () => {
+ it("should resolve links from tidb pages with stable branch", () => {
+ const result = resolveMarkdownLink(
+ "/upgrade/upgrade-tidb-using-tiup",
+ "/en/tidb/stable/upgrade"
+ );
+ expect(result).toBe("/tidb/stable/upgrade-tidb-using-tiup");
+ });
+
+ it("should resolve links from tidb pages with version branch", () => {
+ const result = resolveMarkdownLink(
+ "/upgrade/upgrade-tidb-using-tiup",
+ "/en/tidb/v8.5/upgrade"
+ );
+ expect(result).toBe("/tidb/v8.5/upgrade-tidb-using-tiup");
+ });
+
+ it("should resolve links from tidb-in-kubernetes pages", () => {
+ const result = resolveMarkdownLink(
+ "/deploy/deploy-tidb-on-kubernetes",
+ "/en/tidb-in-kubernetes/stable/deploy"
+ );
+ expect(result).toBe(
+ "/tidb-in-kubernetes/stable/deploy-tidb-on-kubernetes"
+ );
+ });
+
+ it("should not match non-tidb repo pages (pathConditions check)", () => {
+ const result = resolveMarkdownLink(
+ "/upgrade/upgrade-tidb-using-tiup",
+ "/en/other-repo/stable/upgrade"
+ );
+ // Should not match pathConditions for tidb/tidb-in-kubernetes, no fallback rule
+ expect(result).toBe("/upgrade/upgrade-tidb-using-tiup");
+ });
+
+ it("should resolve links with multiple path segments from tidb pages", () => {
+ const result = resolveMarkdownLink(
+ "/upgrade/a/b/c/page",
+ "/en/tidb/stable/upgrade"
+ );
+ expect(result).toBe("/tidb/stable/page");
+ });
+ });
+
+ describe("linkMappingsByPath - Rule 3: developer/best-practices/api/ai/releases namespace pages", () => {
+ it("should resolve links from develop namespace page", () => {
+ const result = resolveMarkdownLink(
+ "/vector-search/vector-search-overview",
+ "/en/developer/vector-search"
+ );
+ expect(result).toBe("/tidb/stable/vector-search-overview");
+ });
+
+ it("should resolve links from best-practices namespace page", () => {
+ const result = resolveMarkdownLink(
+ "/optimization/query-optimization",
+ "/en/best-practices/optimization"
+ );
+ expect(result).toBe("/tidb/stable/query-optimization");
+ });
+
+ it("should resolve links from api namespace page", () => {
+ const result = resolveMarkdownLink(
+ "/tiproxy/tiproxy-api",
+ "/en/api/tiproxy-api-overview"
+ );
+ expect(result).toBe("/tidb/stable/tiproxy-api");
+ });
+
+ it("should resolve links from ai namespace page", () => {
+ const result = resolveMarkdownLink("/some/linked-doc", "/en/ai/overview");
+ expect(result).toBe("/tidb/stable/linked-doc");
+ });
+
+ it("should resolve links from releases namespace page", () => {
+ const result = resolveMarkdownLink(
+ "/v8.5/release-notes",
+ "/en/releases/v8.5"
+ );
+ // Current page /en/releases/v8.5 doesn't match Rule 3 (releases not in pathConditions)
+ // Should return original link or match other rules
+ // Actually matches Rule 4: /en/releases/v8.5 matches /{lang}/{repo}/{branch}/{...any} where repo=releases, branch=v8.5
+ // But repo="releases" is not in pathConditions ["tidb", "tidb-in-kubernetes"]
+ // So it doesn't match Rule 4 either
+ // Should return original link
+ expect(result).toBe("/v8.5/release-notes");
+ });
+
+ it("should resolve links with multiple path segments from develop namespace", () => {
+ const result = resolveMarkdownLink(
+ "/vector-search/data-types/vector-search-data-types-overview",
+ "/en/developer/vector-search/data-types"
+ );
+ expect(result).toBe("/tidb/stable/vector-search-data-types-overview");
+ });
+
+ it("should resolve links with multiple path segments from best-practices namespace", () => {
+ const result = resolveMarkdownLink(
+ "/optimization/query/query-performance-tuning",
+ "/en/best-practices/optimization/query"
+ );
+ expect(result).toBe("/tidb/stable/query-performance-tuning");
+ });
+
+ it("should resolve links with multiple path segments from api namespace", () => {
+ const result = resolveMarkdownLink(
+ "/overview/api-reference/getting-started",
+ "/en/api/overview/api-reference"
+ );
+ expect(result).toBe("/tidb/stable/getting-started");
+ });
+
+ it("should resolve links with multiple path segments from releases namespace", () => {
+ const result = resolveMarkdownLink(
+ "/v8.5/whats-new/features",
+ "/en/releases/v8.5/whats-new"
+ );
+ // Current page /en/releases/v8.5/whats-new doesn't match Rule 3 (releases not in pathConditions)
+ // Should return original link or match other rules
+ // Actually matches Rule 4: /en/releases/v8.5/whats-new matches /{lang}/{repo}/{branch}/{...any} where repo=releases, branch=v8.5
+ // But repo="releases" is not in pathConditions ["tidb", "tidb-in-kubernetes"]
+ // So it doesn't match Rule 4 either
+ // Should return original link
+ expect(result).toBe("/v8.5/whats-new/features");
+ });
+
+ it("should preserve hash for links from develop namespace", () => {
+ const result = resolveMarkdownLink(
+ "/vector-search/vector-search-overview#data-types",
+ "/en/developer/vector-search"
+ );
+ expect(result).toBe("/tidb/stable/vector-search-overview#data-types");
+ });
+
+ it("should preserve hash for links from best-practices namespace", () => {
+ const result = resolveMarkdownLink(
+ "/optimization/query-optimization#index-selection",
+ "/en/best-practices/optimization"
+ );
+ expect(result).toBe("/tidb/stable/query-optimization#index-selection");
+ });
+
+ it("should resolve links from Chinese develop namespace", () => {
+ const result = resolveMarkdownLink(
+ "/vector-search/vector-search-overview",
+ "/zh/developer/vector-search"
+ );
+ expect(result).toBe("/zh/tidb/stable/vector-search-overview");
+ });
+
+ it("should resolve links from Japanese develop namespace", () => {
+ const result = resolveMarkdownLink(
+ "/vector-search/vector-search-overview",
+ "/ja/developer/vector-search"
+ );
+ expect(result).toBe("/ja/tidb/stable/vector-search-overview");
+ });
+
+ it("should resolve single segment links from develop namespace", () => {
+ const result = resolveMarkdownLink(
+ "/page-name",
+ "/en/developer/vector-search"
+ );
+ expect(result).toBe("/tidb/stable/page-name");
+ });
+
+ it("should resolve links from develop namespace root", () => {
+ const result = resolveMarkdownLink(
+ "/vector-search-overview",
+ "/en/developer"
+ );
+ expect(result).toBe("/tidb/stable/vector-search-overview");
+ });
+
+ it("should resolve links from api namespace root", () => {
+ const result = resolveMarkdownLink("/api-overview", "/en/api");
+ expect(result).toBe("/tidb/stable/api-overview");
+ });
+
+ it("should handle links without leading slash from develop namespace", () => {
+ const result = resolveMarkdownLink(
+ "vector-search/vector-search-overview",
+ "/en/developer/vector-search"
+ );
+ expect(result).toBe("/tidb/stable/vector-search-overview");
+ });
+
+ it("should resolve links from best-practices namespace with deep nesting", () => {
+ const result = resolveMarkdownLink(
+ "/a/b/c/d/e/page",
+ "/en/best-practices/a/b/c/d/e"
+ );
+ expect(result).toBe("/tidb/stable/page");
+ });
+
+ it("should resolve links from releases namespace with version segments", () => {
+ const result = resolveMarkdownLink(
+ "/v8.5/changelog/changes",
+ "/en/releases/v8.5/changelog"
+ );
+ // Current page /en/releases/v8.5/changelog doesn't match Rule 3 (releases not in pathConditions)
+ // Should return original link or match other rules
+ // Actually matches Rule 4: /en/releases/v8.5/changelog matches /{lang}/{repo}/{branch}/{...any} where repo=releases, branch=v8.5
+ // But repo="releases" is not in pathConditions ["tidb", "tidb-in-kubernetes"]
+ // So it doesn't match Rule 4 either
+ // Should return original link
+ expect(result).toBe("/v8.5/changelog/changes");
+ });
+
+ it("should not match non-develop/best-practices/api/releases namespace pages", () => {
+ const result = resolveMarkdownLink(
+ "/some/path/to/page",
+ "/en/other-namespace/some/path"
+ );
+ // Should not match Rule 3 (namespace is "other-namespace", not in pathConditions)
+ // Should return original link or match other rules
+ expect(result).toBe("/some/path/to/page");
+ });
+ });
+
+ describe("Default language omission", () => {
+ it("should omit /en/ prefix for English links from tidbcloud pages", () => {
+ const result = resolveMarkdownLink(
+ "/dedicated/getting-started",
+ "/en/tidbcloud/dedicated"
+ );
+ expect(result).toBe("/tidbcloud/getting-started");
+ });
+
+ it("should omit /en/ prefix for English links from tidb pages", () => {
+ const result = resolveMarkdownLink(
+ "/upgrade/upgrade-tidb-using-tiup",
+ "/en/tidb/stable/upgrade"
+ );
+ expect(result).toBe("/tidb/stable/upgrade-tidb-using-tiup");
+ });
+
+ it("should keep /zh/ prefix for Chinese links", () => {
+ const result = resolveMarkdownLink(
+ "/dedicated/getting-started",
+ "/zh/tidbcloud/dedicated"
+ );
+ expect(result).toBe("/zh/tidbcloud/getting-started");
+ });
+
+ it("should keep /ja/ prefix for Japanese links", () => {
+ const result = resolveMarkdownLink(
+ "/upgrade/upgrade-tidb-using-tiup",
+ "/ja/tidb/stable/upgrade"
+ );
+ expect(result).toBe("/ja/tidb/stable/upgrade-tidb-using-tiup");
+ });
+ });
+
+ describe("curLang variable", () => {
+ it("should extract curLang from current page URL first segment (en)", () => {
+ const result = resolveMarkdownLink(
+ "/develop/vector-search",
+ "/en/tidb/stable/alert-rules"
+ );
+ // curLang should be "en" and omitted in result
+ expect(result).toBe("/developer/vector-search");
+ });
+
+ it("should extract curLang from current page URL first segment (zh)", () => {
+ const result = resolveMarkdownLink(
+ "/develop/vector-search",
+ "/zh/tidb/stable/alert-rules"
+ );
+ // curLang should be "zh" and included in result
+ expect(result).toBe("/zh/developer/vector-search");
+ });
+
+ it("should extract curLang from current page URL first segment (ja)", () => {
+ const result = resolveMarkdownLink(
+ "/develop/vector-search",
+ "/ja/tidb/stable/alert-rules"
+ );
+ // curLang should be "ja" and included in result
+ expect(result).toBe("/ja/developer/vector-search");
+ });
+
+ it("should handle curLang when current page URL has no language prefix", () => {
+ const result = resolveMarkdownLink(
+ "/develop/vector-search",
+ "/tidb/stable/alert-rules"
+ );
+ // curLang should be "tidb" (first segment)
+ // Rule 1: /develop/vector-search matches /{namespace}/{...any}/{docname} where namespace=develop, {...any}="", docname=vector-search
+ // Target: /{curLang}/{namespace}/{docname} = /tidb/developer/vector-search
+ // But "tidb" is not the default language "en", so it's included
+ expect(result).toBe("/tidb/developer/vector-search");
+ });
+
+ it("should use curLang in releases/_index target pattern for tidb-self-managed", () => {
+ const result = resolveMarkdownLink(
+ "/releases/_index",
+ "/zh/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/zh/releases/tidb-self-managed");
+ });
+
+ it("should use curLang in tidb-cloud/releases/_index target pattern", () => {
+ const result = resolveMarkdownLink(
+ "/tidb-cloud/releases/_index",
+ "/ja/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/ja/releases/tidb-cloud");
+ });
+
+ it("should use curLang in tidb-in-kubernetes/releases/_index target pattern", () => {
+ const result = resolveMarkdownLink(
+ "/tidb-in-kubernetes/releases/_index",
+ "/ja/tidb-in-kubernetes/stable/deploy"
+ );
+ expect(result).toBe("/ja/releases/tidb-operator");
+ });
+
+ it("should use curLang in namespace links target pattern", () => {
+ const result = resolveMarkdownLink(
+ "/api/overview/introduction",
+ "/zh/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/zh/api/introduction");
+ });
+ });
+
+ describe("Trailing slash handling", () => {
+ it("should remove trailing slash (trailingSlash: never)", () => {
+ const result = resolveMarkdownLink(
+ "/develop/vector-search/",
+ "/en/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/developer/vector-search");
+ });
+
+ it("should handle links without trailing slash", () => {
+ const result = resolveMarkdownLink(
+ "/develop/vector-search",
+ "/en/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/developer/vector-search");
+ });
+ });
+
+ describe("Link path normalization", () => {
+ it("should handle links without leading slash", () => {
+ const result = resolveMarkdownLink(
+ "develop/vector-search",
+ "/en/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/developer/vector-search");
+ });
+
+ it("should handle links with multiple leading slashes", () => {
+ const result = resolveMarkdownLink(
+ "///develop/vector-search",
+ "/en/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/developer/vector-search");
+ });
+
+ it("should handle empty path segments", () => {
+ const result = resolveMarkdownLink(
+ "/develop//vector-search",
+ "/en/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/developer/vector-search");
+ });
+ });
+
+ describe("Edge cases", () => {
+ it("should return original link if current page doesn't match any pathPattern", () => {
+ const result = resolveMarkdownLink(
+ "/unknown/path/to/page",
+ "/en/unknown/current/page"
+ );
+ // No matching rule, should return original link
+ expect(result).toBe("/unknown/path/to/page");
+ });
+
+ it("should handle root path links", () => {
+ const result = resolveMarkdownLink("/", "/en/tidb/stable/alert-rules");
+ expect(result).toBe("/");
+ });
+
+ it("should handle single segment links", () => {
+ const result = resolveMarkdownLink(
+ "/page",
+ "/en/tidb/stable/alert-rules"
+ );
+ // Current page /en/tidb/stable/alert-rules matches Rule 3 /{lang}/{repo}/{branch}/{...any}:
+ // - lang = en, repo = tidb, branch = stable, {...any} = alert-rules
+ // Link /page matches /{...any}/{docname} where {...any} is empty, docname = page
+ // Target pattern /{lang}/{repo}/{branch}/{docname} = /en/tidb/stable/page
+ // After defaultLanguage omission: /tidb/stable/page
+ expect(result).toBe("/tidb/stable/page");
+ });
+
+ it("should handle links with special characters", () => {
+ const result = resolveMarkdownLink(
+ "/develop/page-name-with-dashes",
+ "/en/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/developer/page-name-with-dashes");
+ });
+ });
+
+ describe("Complex scenarios", () => {
+ it("should resolve nested namespace links correctly", () => {
+ const result = resolveMarkdownLink(
+ "/develop/vector-search/vector-search-data-types/vector-search-data-types-overview",
+ "/en/tidb/stable/alert-rules"
+ );
+ expect(result).toBe("/developer/vector-search-data-types-overview");
+ });
+
+ it("should resolve links from tidbcloud with multiple prefixes", () => {
+ const result = resolveMarkdownLink(
+ "/dedicated/starter/setup/config",
+ "/en/tidbcloud/dedicated/starter"
+ );
+ expect(result).toBe("/tidbcloud/config");
+ });
+
+ it("should resolve links from tidb with deep folder structure", () => {
+ const result = resolveMarkdownLink(
+ "/upgrade/from-v7/to-v8/upgrade-guide",
+ "/en/tidb/stable/upgrade/from-v7"
+ );
+ expect(result).toBe("/tidb/stable/upgrade-guide");
+ });
+ });
+});
diff --git a/gatsby/link-resolver/config.ts b/gatsby/link-resolver/config.ts
new file mode 100644
index 000000000..b0d66b80d
--- /dev/null
+++ b/gatsby/link-resolver/config.ts
@@ -0,0 +1,70 @@
+/**
+ * Default link resolver configuration
+ */
+
+import type { LinkResolverConfig } from "./types";
+
+export const defaultLinkResolverConfig: LinkResolverConfig = {
+ // Default language to omit from resolved URLs
+ defaultLanguage: "en",
+
+ linkMappings: [
+ {
+ linkPattern: "/releases/_index",
+ targetPattern: "/{curLang}/releases/tidb-self-managed",
+ },
+ {
+ linkPattern: "/tidb-cloud/releases/_index",
+ targetPattern: "/{curLang}/releases/tidb-cloud",
+ },
+ {
+ pathPattern: "/{lang}/tidb-in-kubernetes/{branch}/{...any}",
+ linkPattern: "/tidb-in-kubernetes/releases/_index",
+ targetPattern: "/{curLang}/releases/tidb-operator",
+ },
+ // Rule 1: Links starting with specific namespaces (direct link mapping)
+ // /{namespace}/{...any}/{docname} -> /{curLang}/{namespace}/{docname}
+ // Special: tidb-cloud -> tidbcloud, develop -> developer
+ {
+ linkPattern: "/{namespace}/{...any}/{docname}",
+ targetPattern: "/{curLang}/{namespace}/{docname}",
+ conditions: {
+ namespace: ["tidb-cloud", "develop", "best-practices", "api", "ai"],
+ },
+ namespaceTransform: {
+ "tidb-cloud": "tidbcloud",
+ develop: "developer",
+ },
+ },
+ // Rule 2: tidbcloud with prefix pages (path-based mapping)
+ // Current page: /{lang}/tidbcloud/{...any}
+ // Link: /{...any}/{docname} -> /{lang}/tidbcloud/{docname}
+ {
+ pathPattern: "/{lang}/tidbcloud/{...any}",
+ linkPattern: "/{...any}/{docname}",
+ targetPattern: "/{lang}/tidbcloud/{docname}",
+ },
+ // Rule 3: developer, best-practices, api, ai, releases namespace in tidb folder
+ // Current page: /{lang}/{namespace}/{...any}
+ // Link: /{...any}/{docname} -> /{lang}/{namespace}/{docname}
+ {
+ pathPattern: `/{lang}/{namespace}/{...any}`,
+ pathConditions: {
+ namespace: ["developer", "best-practices", "api", "ai"],
+ },
+ linkPattern: "/{...any}/{docname}",
+ targetPattern: "/{lang}/tidb/stable/{docname}",
+ },
+ // Rule 4: tidb with branch pages (path-based mapping)
+ // Current page: /{lang}/tidb/{branch}/{...any} (branch is already aliased, e.g., "stable", "v8.5")
+ // Link: /{...any}/{docname} -> /{lang}/tidb/{branch}/{docname}
+ {
+ pathPattern: "/{lang}/{repo}/{branch}/{...any}",
+ pathConditions: {
+ repo: ["tidb", "tidb-in-kubernetes"],
+ },
+ linkPattern: "/{...any}/{docname}",
+ targetPattern: "/{lang}/{repo}/{branch}/{docname}",
+ },
+ ],
+};
diff --git a/gatsby/link-resolver/index.ts b/gatsby/link-resolver/index.ts
new file mode 100644
index 000000000..ffba02eed
--- /dev/null
+++ b/gatsby/link-resolver/index.ts
@@ -0,0 +1,16 @@
+/**
+ * Link Resolver - Main entry point
+ *
+ * This module provides utilities for:
+ * - Resolving markdown links within articles based on mapping rules
+ * - Context-based link resolution based on current page URL
+ */
+
+// Export types
+export type { LinkMappingRule, LinkResolverConfig } from "./types";
+
+// Export link resolver functions
+export { resolveMarkdownLink, clearLinkResolverCache } from "./link-resolver";
+
+// Export default configuration
+export { defaultLinkResolverConfig } from "./config";
diff --git a/gatsby/link-resolver/link-resolver.ts b/gatsby/link-resolver/link-resolver.ts
new file mode 100644
index 000000000..09b51d1a8
--- /dev/null
+++ b/gatsby/link-resolver/link-resolver.ts
@@ -0,0 +1,219 @@
+/**
+ * Link resolver for transforming markdown links within articles
+ */
+
+import { matchPattern, applyPattern } from "../url-resolver/pattern-matcher";
+import { defaultUrlResolverConfig } from "../url-resolver/config";
+import { defaultLinkResolverConfig } from "./config";
+
+// Cache for resolveMarkdownLink results
+// Key: linkPath + currentPageUrl
+// Value: resolved URL or original linkPath
+const linkResolverCache = new Map();
+
+// Cache for parseLinkPath results
+// Key: linkPath
+// Value: parsed segments array
+const parsedLinkPathCache = new Map();
+
+/**
+ * Parse link path into segments
+ */
+function parseLinkPath(linkPath: string): string[] {
+ // Check cache first
+ const cached = parsedLinkPathCache.get(linkPath);
+ if (cached !== undefined) {
+ return cached;
+ }
+
+ // Remove leading and trailing slashes, then split
+ const normalized = linkPath.replace(/^\/+|\/+$/g, "");
+ if (!normalized) {
+ parsedLinkPathCache.set(linkPath, []);
+ return [];
+ }
+ const segments = normalized.split("/").filter((s) => s.length > 0);
+ parsedLinkPathCache.set(linkPath, segments);
+ return segments;
+}
+
+/**
+ * Check if conditions are met
+ */
+function checkConditions(
+ conditions: Record | undefined,
+ variables: Record
+): boolean {
+ if (!conditions) return true;
+
+ for (const [variableName, allowedValues] of Object.entries(conditions)) {
+ const variableValue = variables[variableName];
+ if (variableValue && allowedValues) {
+ if (!allowedValues.includes(variableValue)) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+}
+
+/**
+ * Resolve markdown link based on mapping rules
+ * Uses global defaultLinkResolverConfig and defaultUrlResolverConfig
+ *
+ * @param linkPath - The markdown link path to resolve
+ * @param currentPageUrl - The current page URL for context-based resolution
+ */
+export function resolveMarkdownLink(
+ linkPath: string,
+ currentPageUrl: string
+): string | null {
+ // Early exit for external links and anchor links (most common case)
+ if (!linkPath || linkPath.startsWith("http") || linkPath.startsWith("#")) {
+ return linkPath;
+ }
+
+ // Check cache
+ const cacheKey = `${linkPath}::${currentPageUrl}`;
+ const cached = linkResolverCache.get(cacheKey);
+ if (cached !== undefined) {
+ return cached;
+ }
+
+ const linkConfig = defaultLinkResolverConfig;
+ const urlConfig = defaultUrlResolverConfig;
+
+ // Normalize link path
+ const normalizedLink = linkPath.startsWith("/") ? linkPath : "/" + linkPath;
+ const linkSegments = parseLinkPath(normalizedLink);
+
+ // Early exit for empty links
+ if (linkSegments.length === 0) {
+ linkResolverCache.set(cacheKey, linkPath);
+ return linkPath;
+ }
+
+ // Process all rules in order (first match wins)
+ const currentPageSegments = parseLinkPath(currentPageUrl);
+
+ // Extract curLang from the first segment of currentPageUrl
+ const curLang = currentPageSegments.length > 0 ? currentPageSegments[0] : "";
+
+ for (const rule of linkConfig.linkMappings) {
+ let variables: Record | null = null;
+
+ // Check if this is a direct link mapping (linkPattern only) or path-based mapping (pathPattern + linkPattern)
+ if (!rule.pathPattern) {
+ // Direct link mapping: match link path directly
+ variables = matchPattern(rule.linkPattern, linkSegments);
+ if (!variables) {
+ continue;
+ }
+
+ // Add curLang as default variable
+ if (curLang) {
+ variables.curLang = curLang;
+ }
+
+ // Check conditions
+ if (rule.conditions) {
+ let conditionsMet = true;
+ for (const [varName, allowedValues] of Object.entries(
+ rule.conditions
+ )) {
+ const varValue = variables[varName];
+ if (varValue && allowedValues) {
+ if (!allowedValues.includes(varValue)) {
+ conditionsMet = false;
+ break;
+ }
+ }
+ }
+ if (!conditionsMet) {
+ continue;
+ }
+ }
+
+ // Apply namespace transformation if needed
+ if (rule.namespaceTransform && variables.namespace) {
+ const transformed = rule.namespaceTransform[variables.namespace];
+ if (transformed) {
+ variables.namespace = transformed;
+ }
+ }
+ } else {
+ // Path-based mapping: match current page path first, then link path
+ const pageVars = matchPattern(rule.pathPattern, currentPageSegments);
+ if (!pageVars) {
+ continue;
+ }
+
+ // Check path conditions (if specified, check against page variables)
+ if (rule.pathConditions) {
+ if (!checkConditions(rule.pathConditions, pageVars)) {
+ continue;
+ }
+ }
+
+ // Check conditions (if specified, check against page variables as fallback)
+ if (rule.conditions && !rule.pathConditions) {
+ if (!checkConditions(rule.conditions, pageVars)) {
+ continue;
+ }
+ }
+
+ // Match link pattern
+ const linkVars = matchPattern(rule.linkPattern, linkSegments);
+ if (!linkVars) {
+ continue;
+ }
+
+ // Merge current page variables with link variables
+ variables = { ...pageVars, ...linkVars };
+
+ // Add curLang as default variable
+ if (curLang) {
+ variables.curLang = curLang;
+ }
+
+ // Set default values for missing variables
+ // For tidb pages without lang prefix, default to "en"
+ if (pageVars.repo === "tidb" && !variables.lang) {
+ variables.lang = "en";
+ }
+ }
+
+ // Build target URL
+ const targetUrl = applyPattern(rule.targetPattern, variables, urlConfig);
+
+ // Handle default language and trailing slash
+ let result = targetUrl;
+ // Use linkConfig.defaultLanguage if available, otherwise fallback to urlConfig.defaultLanguage
+ const defaultLanguage =
+ linkConfig.defaultLanguage || urlConfig.defaultLanguage;
+ if (defaultLanguage && result.startsWith(`/${defaultLanguage}/`)) {
+ result = result.replace(`/${defaultLanguage}/`, "/");
+ }
+ if (urlConfig.trailingSlash === "never") {
+ result = result.replace(/\/$/, "");
+ }
+
+ // Cache the result
+ linkResolverCache.set(cacheKey, result);
+ return result;
+ }
+
+ // No match found, return original link
+ // Cache the original linkPath
+ linkResolverCache.set(cacheKey, linkPath);
+ return linkPath;
+}
+
+/**
+ * Clear link resolver cache (useful for testing or when config changes)
+ */
+export function clearLinkResolverCache(): void {
+ linkResolverCache.clear();
+ parsedLinkPathCache.clear();
+}
diff --git a/gatsby/link-resolver/types.ts b/gatsby/link-resolver/types.ts
new file mode 100644
index 000000000..8b4090e56
--- /dev/null
+++ b/gatsby/link-resolver/types.ts
@@ -0,0 +1,30 @@
+/**
+ * Type definitions for link resolver
+ */
+
+export interface LinkMappingRule {
+ // Pattern to match current page path (for path-based mapping, optional)
+ // e.g., "/tidbcloud/{...any}" or "/{lang}/{repo}/{branch:branch-alias}/{...any}"
+ // If not specified, this is a direct link mapping
+ pathPattern?: string;
+ // Pattern to match link path
+ // e.g., "/{namespace}/{...any}/{docname}" or "/{...any}/{docname}"
+ linkPattern: string;
+ // Target URL pattern
+ // e.g., "/{namespace}/{docname}" or "/{lang}/tidbcloud/{docname}"
+ targetPattern: string;
+ // Conditions for this rule to apply (checked against link variables or merged variables)
+ conditions?: Record;
+ // Conditions for current page path variables (checked against variables extracted from pathPattern)
+ pathConditions?: Record;
+ // Namespace transformation (e.g., "tidb-cloud" -> "tidbcloud")
+ namespaceTransform?: Record;
+}
+
+export interface LinkResolverConfig {
+ // Link mapping rules (ordered, first match wins)
+ // Rules can be either direct link mappings (linkPattern only) or path-based mappings (pathPattern + linkPattern)
+ linkMappings: LinkMappingRule[];
+ // Default language to omit from resolved URLs (e.g., "en" -> /tidb/stable instead of /en/tidb/stable)
+ defaultLanguage?: string;
+}
diff --git a/gatsby/path/getTOCPath.ts b/gatsby/path/getTOCPath.ts
new file mode 100644
index 000000000..693672803
--- /dev/null
+++ b/gatsby/path/getTOCPath.ts
@@ -0,0 +1,201 @@
+import {
+ Locale,
+ PathConfig,
+ Repo,
+ TOCNamespace,
+} from "../../src/shared/interface";
+import CONFIG from "../../docs/docs.json";
+
+export function generateNavTOCPath(config: PathConfig, postSlug: string) {
+ return `${config.locale}/${config.repo}/${config.branch}/TOC${
+ postSlug ? `-${postSlug}` : ""
+ }`;
+}
+
+/**
+ * Namespace matching rule configuration
+ */
+export interface NamespaceRule {
+ /** Target namespace to return when matched */
+ namespace: TOCNamespace;
+ /** Repo to match against (optional, matches all if not specified) */
+ repo?: Repo | Repo[];
+ /** Branch to match against (optional, matches all if not specified) */
+ branch?: string | string[] | ((branch: string) => boolean);
+ /** Folder name to match against (optional, matches all if not specified) */
+ folder?: string | string[];
+ /** Rest path segments to match against (optional) */
+ restPath?: string | string[] | ((rest: string[]) => boolean);
+ /** Minimum number of rest path segments required */
+ minRestLength?: number;
+}
+
+/**
+ * Configuration for shared namespace rules
+ * Add new rules here to extend namespace matching logic
+ */
+const SHARED_NAMESPACE_RULES: NamespaceRule[] = [
+ {
+ namespace: TOCNamespace.Develop,
+ repo: Repo.tidb,
+ branch: CONFIG.docs.tidb.stable,
+ folder: "develop",
+ minRestLength: 1,
+ },
+ {
+ namespace: TOCNamespace.BestPractices,
+ repo: Repo.tidb,
+ branch: CONFIG.docs.tidb.stable,
+ folder: "best-practices",
+ minRestLength: 1,
+ },
+ {
+ namespace: TOCNamespace.API,
+ repo: Repo.tidb,
+ branch: CONFIG.docs.tidb.stable,
+ folder: "api",
+ minRestLength: 1,
+ },
+ {
+ namespace: TOCNamespace.TiDBReleases,
+ repo: Repo.tidb,
+ branch: CONFIG.docs.tidb.stable,
+ folder: "releases",
+ minRestLength: 1,
+ },
+ {
+ namespace: TOCNamespace.TidbCloudReleases,
+ repo: Repo.tidbcloud,
+ folder: "tidb-cloud",
+ restPath: (rest) => rest[0] === "releases",
+ },
+ {
+ namespace: TOCNamespace.TiDBInKubernetesReleases,
+ repo: Repo.operator,
+ branch: "main",
+ folder: "releases",
+ minRestLength: 1,
+ },
+ {
+ namespace: TOCNamespace.TiDB,
+ repo: Repo.tidb,
+ },
+ {
+ namespace: TOCNamespace.TiDBCloud,
+ repo: Repo.tidbcloud,
+ },
+ {
+ namespace: TOCNamespace.TiDBInKubernetes,
+ repo: Repo.operator,
+ },
+];
+
+/**
+ * Check if a value matches the rule condition
+ */
+function matchesValue(
+ value: string | undefined,
+ condition?: string | string[] | ((val: string) => boolean)
+): boolean {
+ if (condition === undefined) {
+ return true;
+ }
+
+ if (typeof condition === "function") {
+ return value !== undefined && condition(value);
+ }
+
+ if (typeof condition === "string") {
+ return value === condition;
+ }
+
+ return condition.includes(value!);
+}
+
+/**
+ * Check if an array matches the rule condition
+ */
+function matchesArray(
+ value: string[],
+ condition?: string | string[] | ((arr: string[]) => boolean)
+): boolean {
+ if (condition === undefined) {
+ return true;
+ }
+
+ if (typeof condition === "function") {
+ return condition(value);
+ }
+
+ if (typeof condition === "string") {
+ return value.includes(condition);
+ }
+
+ return condition.some((c) => value.includes(c));
+}
+
+/**
+ * Check if a rule matches the given path segments
+ */
+function matchesRule(
+ rule: NamespaceRule,
+ repo: Repo,
+ branch: string,
+ folder: string | undefined,
+ rest: string[]
+): boolean {
+ // Check repo
+ if (rule.repo !== undefined) {
+ const repos = Array.isArray(rule.repo) ? rule.repo : [rule.repo];
+ if (!repos.includes(repo)) {
+ return false;
+ }
+ }
+
+ // Check branch
+ if (!matchesValue(branch, rule.branch)) {
+ return false;
+ }
+
+ // Check folder
+ if (!matchesValue(folder, rule.folder)) {
+ return false;
+ }
+
+ // Check minimum rest length
+ if (rule.minRestLength !== undefined && rest.length < rule.minRestLength) {
+ return false;
+ }
+
+ // Check rest path
+ if (rule.restPath !== undefined) {
+ if (!matchesArray(rest, rule.restPath)) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+/**
+ * Get shared namespace from slug based on configured rules
+ * Returns the first matching namespace or empty string if no match
+ */
+export const getTOCNamespace = (slug: string): TOCNamespace | undefined => {
+ const [locale, repo, branch, folder, ...rest] = slug.split("/") as [
+ Locale,
+ Repo,
+ string,
+ string,
+ ...string[]
+ ];
+
+ // Find the first matching rule
+ for (const rule of SHARED_NAMESPACE_RULES) {
+ if (matchesRule(rule, repo, branch, folder, rest)) {
+ return rule.namespace;
+ }
+ }
+
+ return;
+};
diff --git a/gatsby/path.ts b/gatsby/path/index.ts
similarity index 85%
rename from gatsby/path.ts
rename to gatsby/path/index.ts
index 9e5ed6952..9fd057395 100644
--- a/gatsby/path.ts
+++ b/gatsby/path/index.ts
@@ -1,6 +1,15 @@
-import { Locale, Repo, PathConfig, CloudPlan } from "../src/shared/interface";
-import CONFIG from "../docs/docs.json";
-
+import {
+ Locale,
+ Repo,
+ PathConfig,
+ CloudPlan,
+} from "../../src/shared/interface";
+import CONFIG from "../../docs/docs.json";
+
+// Re-export getSharedNamespace from namespace module
+export * from "./getTOCPath";
+
+// @deprecated, use calculateFileUrl instead
export function generateUrl(filename: string, config: PathConfig) {
const lang = config.locale === Locale.en ? "" : `/${config.locale}`;
@@ -27,16 +36,6 @@ export function generatePdfUrl(config: PathConfig) {
}-manual.pdf`;
}
-export function generateNav(config: PathConfig) {
- return `${config.locale}/${config.repo}/${config.branch}/TOC`;
-}
-export function generateStarterNav(config: PathConfig) {
- return `${config.locale}/${config.repo}/${config.branch}/TOC-tidb-cloud-starter`;
-}
-export function generateEssentialNav(config: PathConfig) {
- return `${config.locale}/${config.repo}/${config.branch}/TOC-tidb-cloud-essential`;
-}
-
export function generateConfig(slug: string): {
config: PathConfig;
filePath: string;
diff --git a/gatsby/plugin/content/index.ts b/gatsby/plugin/content/index.ts
index fc37babfb..6750ba89d 100644
--- a/gatsby/plugin/content/index.ts
+++ b/gatsby/plugin/content/index.ts
@@ -1,30 +1,32 @@
-import visit from 'unist-util-visit'
-import type { Root, Link, Blockquote } from 'mdast'
+import visit from "unist-util-visit";
+import type { Root, Link, Blockquote } from "mdast";
+import { calculateFileUrl } from "../../url-resolver";
+import { resolveMarkdownLink } from "../../link-resolver";
function textToJsx(text: string) {
switch (text) {
- case 'Note:':
- case '注意:':
- case 'Note':
- case '注意':
- return 'Note'
- case 'Warning:':
- case '警告:':
- case 'Warning':
- case '警告':
- return 'Warning'
- case 'Tip:':
- case '建议:':
- case 'Tip':
- case '建议':
- return 'Tip'
- case 'Important:':
- case '重要:':
- case 'Important':
- case '重要':
- return 'Important'
+ case "Note:":
+ case "注意:":
+ case "Note":
+ case "注意":
+ return "Note";
+ case "Warning:":
+ case "警告:":
+ case "Warning":
+ case "警告":
+ return "Warning";
+ case "Tip:":
+ case "建议:":
+ case "Tip":
+ case "建议":
+ return "Tip";
+ case "Important:":
+ case "重要:":
+ case "Important":
+ case "重要":
+ return "Important";
default:
- throw new Error('unreachable')
+ throw new Error("unreachable");
}
}
@@ -32,86 +34,89 @@ module.exports = function ({
markdownAST,
markdownNode,
}: {
- markdownAST: Root
- markdownNode: { fileAbsolutePath: string }
+ markdownAST: Root;
+ markdownNode: { fileAbsolutePath: string };
}) {
+ const currentFileUrl = calculateFileUrl(markdownNode.fileAbsolutePath) || "";
+
visit(markdownAST, (node: any) => {
if (Array.isArray(node.children)) {
node.children = node.children.flatMap((node: any) => {
- if (node.type === 'link' && !node.url.startsWith('#')) {
- const ele = node as Link
+ if (node.type === "link" && !node.url.startsWith("#")) {
+ const ele = node as Link;
- if (ele.url.startsWith('http')) {
+ if (ele.url.startsWith("http")) {
return [
{
- type: 'jsx',
+ type: "jsx",
value: ``,
},
...node.children,
- { type: 'jsx', value: '' },
- ]
+ { type: "jsx", value: "" },
+ ];
} else {
- const urlSeg = ele.url.split('/')
- const fileName = urlSeg[urlSeg.length - 1].replace('.md', '')
- const path = markdownNode.fileAbsolutePath.endsWith('_index.md')
- ? fileName
- : '../' + fileName
+ // Resolve markdown link using link-resolver
+ const resolvedPath = resolveMarkdownLink(
+ ele.url.replace(".md", ""),
+ currentFileUrl
+ );
+
return [
{
- type: 'jsx',
- value: ``,
+ type: "jsx",
+ value: ``,
},
...node.children,
- { type: 'jsx', value: '' },
- ]
+ { type: "jsx", value: "" },
+ ];
}
}
- if (node.type === 'blockquote') {
- const ele = node as Blockquote
- const first = ele.children[0]
+ if (node.type === "blockquote") {
+ const ele = node as Blockquote;
+ const first = ele.children[0];
if (
- first?.type === 'paragraph' &&
- first.children?.[0].type === 'strong' &&
- first.children[0].children?.[0].type === 'text'
+ first?.type === "paragraph" &&
+ first.children?.[0].type === "strong" &&
+ first.children[0].children?.[0].type === "text"
) {
- const text = first.children[0].children[0].value
+ const text = first.children[0].children[0].value;
switch (text) {
- case 'Note:':
+ case "Note:":
// https://github.com/orgs/community/discussions/16925
- case 'Note':
- case '注意:':
- case '注意':
- case 'Warning:':
- case 'Warning':
- case '警告:':
- case '警告':
- case 'Tip:':
- case 'Tip':
- case '建议:':
- case '建议':
- case 'Important:':
- case 'Important':
- case '重要:':
- case '重要': {
- const children = node.children.slice(1)
- const jsx = textToJsx(text)
+ case "Note":
+ case "注意:":
+ case "注意":
+ case "Warning:":
+ case "Warning":
+ case "警告:":
+ case "警告":
+ case "Tip:":
+ case "Tip":
+ case "建议:":
+ case "建议":
+ case "Important:":
+ case "Important":
+ case "重要:":
+ case "重要": {
+ const children = node.children.slice(1);
+ const jsx = textToJsx(text);
return [
- { type: 'jsx', value: `<${jsx}>` },
+ { type: "jsx", value: `<${jsx}>` },
...children,
- { type: 'jsx', value: `${jsx}>` },
- ]
+ { type: "jsx", value: `${jsx}>` },
+ ];
}
default:
- return ele
+ return ele;
}
}
- return ele
+ return ele;
}
- return node
- })
+ return node;
+ });
}
- })
-}
+ });
+};
diff --git a/gatsby/toc-filter.ts b/gatsby/toc-filter.ts
index d7361f7fc..e3c517e90 100644
--- a/gatsby/toc-filter.ts
+++ b/gatsby/toc-filter.ts
@@ -1,5 +1,6 @@
import { mdxAstToToc, TocQueryData } from "./toc";
import { generateConfig } from "./path";
+import { calculateFileUrl } from "./url-resolver";
// Whitelist of files that should always be built regardless of TOC content
const WHITELIST = [""];
@@ -78,7 +79,8 @@ export async function getFilesFromTocs(
filteredTocNodes.forEach((node: TocQueryData["allMdx"]["nodes"][0]) => {
const { config } = generateConfig(node.slug);
- const toc = mdxAstToToc(node.mdxAST.children, config);
+ const tocPath = calculateFileUrl(node.slug);
+ const toc = mdxAstToToc(node.mdxAST.children, tocPath || node.slug);
const files = extractFilesFromToc(toc);
// Create a key for this specific locale/repo/version combination
diff --git a/gatsby/toc.ts b/gatsby/toc.ts
index 96cde833e..fef4f3d35 100644
--- a/gatsby/toc.ts
+++ b/gatsby/toc.ts
@@ -9,8 +9,9 @@ import {
Heading,
} from "mdast";
-import { RepoNav, RepoNavLink, PathConfig } from "../src/shared/interface";
-import { generateUrl } from "./path";
+import { RepoNav, RepoNavLink } from "../src/shared/interface";
+import { calculateFileUrl } from "./url-resolver";
+import { resolveMarkdownLink } from "./link-resolver";
const SKIP_MODE_HEADING = "_BUILD_ALLOWLIST";
@@ -82,11 +83,12 @@ export interface TocQueryData {
export function mdxAstToToc(
ast: Content[],
- tocConfig: PathConfig,
+ tocSlug: string,
prefixId = `0`,
filterWhitelist = false
): RepoNav {
const filteredAst = filterWhitelist ? filterWhitelistContent(ast) : ast;
+ const tocPath = calculateFileUrl(tocSlug) || "";
return filteredAst
.filter(
@@ -95,7 +97,7 @@ export function mdxAstToToc(
)
.map((node, idx) => {
if (node.type === "list") {
- return handleList(node.children, tocConfig, `${prefixId}-${idx}`);
+ return handleList(node.children, tocPath, `${prefixId}-${idx}`);
} else {
return handleHeading((node as Heading).children, `${prefixId}-${idx}`);
}
@@ -103,15 +105,11 @@ export function mdxAstToToc(
.flat();
}
-function handleList(ast: ListItem[], tocConfig: PathConfig, prefixId = `0`) {
+function handleList(ast: ListItem[], tocPath: string, prefixId = `0`) {
return ast.map((node, idx) => {
const content = node.children as [Paragraph, List | undefined];
if (content.length > 0 && content.length <= 2) {
- const ret = getContentFromLink(
- content[0],
- tocConfig,
- `${prefixId}-${idx}`
- );
+ const ret = getContentFromLink(content[0], tocPath, `${prefixId}-${idx}`);
if (content[1]) {
const list = content[1];
@@ -121,11 +119,7 @@ function handleList(ast: ListItem[], tocConfig: PathConfig, prefixId = `0`) {
);
}
- ret.children = handleList(
- list.children,
- tocConfig,
- `${prefixId}-${idx}`
- );
+ ret.children = handleList(list.children, tocPath, `${prefixId}-${idx}`);
}
return ret;
@@ -152,7 +146,7 @@ function handleHeading(ast: PhrasingContent[], id = `0`): RepoNavLink[] {
function getContentFromLink(
content: Paragraph,
- tocConfig: PathConfig,
+ tocPath: string,
id: string
): RepoNavLink {
if (content.type !== "paragraph" || content.children.length === 0) {
@@ -195,12 +189,10 @@ function getContentFromLink(
};
}
- const urlSegs = child.url.split("/");
- let filename = urlSegs[urlSegs.length - 1].replace(".md", "");
-
return {
type: "nav",
- link: generateUrl(filename, tocConfig),
+ link:
+ resolveMarkdownLink(child.url.replace(".md", ""), tocPath || "") || "",
content,
tag,
id,
diff --git a/gatsby/url-resolver/README.md b/gatsby/url-resolver/README.md
new file mode 100644
index 000000000..e9480a372
--- /dev/null
+++ b/gatsby/url-resolver/README.md
@@ -0,0 +1,346 @@
+# URL Resolver
+
+## Overview
+
+The URL Resolver is a core module that maps source file paths to published URLs during the Gatsby build process. It provides a flexible, pattern-based configuration system for transforming file paths into clean, SEO-friendly URLs.
+
+## Purpose
+
+The URL Resolver serves the following purposes:
+
+1. **Path Transformation**: Converts source file paths (e.g., `/docs/markdown-pages/en/tidb/master/alert-rules.md`) into published URLs (e.g., `/tidb/dev/alert-rules`)
+2. **Branch Aliasing**: Maps internal branch names (e.g., `master`, `release-8.5`) to display versions (e.g., `dev`, `v8.5`)
+3. **Namespace Handling**: Handles special namespaces like `developer` (source folder `develop`), `best-practices`, `api`, and `releases` with custom URL structures
+4. **Default Language Omission**: Optionally omits the default language prefix (e.g., `/en/`) from URLs
+5. **Trailing Slash Control**: Configures trailing slash behavior (`always`, `never`, or `auto`)
+
+## Usage
+
+### Basic Usage
+
+```typescript
+import { calculateFileUrl } from "../../gatsby/url-resolver";
+
+// Calculate URL from absolute file path
+const url = calculateFileUrl(
+ "/path/to/docs/markdown-pages/en/tidb/master/alert-rules.md",
+ true // omitDefaultLanguage: omit /en/ prefix for default language
+);
+// Result: "/tidb/dev/alert-rules"
+
+// Calculate URL from slug format (relative path)
+const url2 = calculateFileUrl("en/tidb/master/alert-rules", true);
+// Result: "/tidb/dev/alert-rules"
+```
+
+### Usage in Gatsby
+
+The URL Resolver is used in `gatsby/create-pages/create-docs.ts` to generate page URLs:
+
+```typescript
+import { calculateFileUrl } from "../../gatsby/url-resolver";
+
+nodes.forEach((node) => {
+ // node.slug is in format: "en/tidb/master/alert-rules"
+ const path = calculateFileUrl(node.slug, true);
+
+ if (!path) {
+ console.info(`Failed to calculate URL for ${node.slug}`);
+ return;
+ }
+
+ createPage({
+ path,
+ component: template,
+ context: { /* ... */ },
+ });
+});
+```
+
+## Configuration
+
+### Configuration Structure
+
+The URL Resolver configuration is defined in `gatsby/url-resolver/config.ts`:
+
+```typescript
+export const defaultUrlResolverConfig: UrlResolverConfig = {
+ sourceBasePath: path.resolve(__dirname, "../../docs/markdown-pages"),
+ defaultLanguage: "en",
+ trailingSlash: "never",
+
+ pathMappings: [
+ // Rules are evaluated in order, first match wins
+ {
+ sourcePattern: "/{lang}/tidb/{branch}/{...folders}/{filename}",
+ targetPattern: "/{lang}/tidb/{branch:branch-alias-tidb}/{filename}",
+ filenameTransform: {
+ ignoreIf: ["_index", "_docHome"],
+ },
+ },
+ // ... more rules
+ ],
+
+ aliases: {
+ "branch-alias-tidb": {
+ mappings: {
+ master: "dev",
+ "release-*": "v*", // Wildcard pattern
+ },
+ },
+ },
+};
+```
+
+### `UrlResolverConfig` Options
+
+- `sourceBasePath` (`string`): Base directory of the source markdown files. Used by `parseSourcePath()` to convert absolute file paths into slug-like relative paths.
+- `pathMappings` (`PathMappingRule[]`): Ordered mapping rules (first match wins).
+ - `sourcePattern` (`string`): Pattern to match the parsed source path (supports `{var}` and `{...var}` variables).
+ - `targetPattern` (`string`): Pattern to generate the published URL. Supports alias syntax like `{branch:branch-alias-tidb}`.
+ - `conditions` (`Record`, optional): Restricts when the rule applies by allowing only specific values for variables extracted from `sourcePattern`.
+ - `filenameTransform` (optional): Special handling for filenames like `_index`/`_docHome`.
+ - `ignoreIf` (`string[]`, optional): If the filename matches, omit the `{filename}` part in the generated URL.
+ - `conditionalTarget` (optional): Use an alternate `keepTargetPattern` when the filename matches `keepIf`.
+- `aliases` (optional): Named alias tables referenced from `targetPattern` via `{var:alias-name}`.
+ - `context` (`Record`, optional): Apply the alias only when other variables match specific values (for example, only for certain `repo` values).
+ - `mappings` (`AliasMapping`): Alias definitions supporting exact matches (for example, `master -> dev`) and wildcard/regex replacements (for example, `release-* -> v*`).
+- `defaultLanguage` (`string`, optional): When `calculateFileUrl(..., omitDefaultLanguage=true)` and the resolved URL starts with `/{defaultLanguage}/`, the language prefix is removed.
+- `trailingSlash` (`"always" | "never" | "auto"`, optional): Controls trailing slash behavior of the resolved URL.
+ - `"never"`: never ends with `/`
+ - `"always"`: always ends with `/`
+ - `"auto"`: add/remove `/` based on whether the resolved URL represents an index page (for example, when `{filename}` is omitted)
+
+### Pattern Syntax
+
+#### Variables
+
+- `{lang}` - Language code (e.g., `en`, `zh`, `ja`)
+- `{repo}` - Repository name (e.g., `tidb`, `tidbcloud`, `tidb-in-kubernetes`)
+- `{branch}` - Branch name (e.g., `master`, `release-8.5`)
+- `{filename}` - File name without extension (e.g., `alert-rules`, `_index`)
+- `{...folders}` - Variable number of folder segments (captures all remaining segments)
+- `{namespace}` - Namespace (e.g., `developer`, `best-practices`, `api`)
+
+#### Alias Syntax
+
+Use `{variable:alias-name}` in target patterns to apply aliases:
+
+```typescript
+// In targetPattern: "{branch:branch-alias-tidb}"
+// This applies the "branch-alias-tidb" alias to the branch variable
+```
+
+#### Filename Transform
+
+```typescript
+filenameTransform: {
+ // Ignore filename in target URL if it matches any value
+ ignoreIf: ["_index", "_docHome"],
+
+ // Use alternative target pattern when filename matches
+ conditionalTarget: {
+ keepIf: ["_index"],
+ keepTargetPattern: "/{lang}/{namespace}/{folders}",
+ },
+}
+```
+
+### Example Rules
+
+#### 1. TiDBCloud Dedicated Index
+
+```typescript
+{
+ sourcePattern: "/{lang}/tidbcloud/master/tidb-cloud/dedicated/{filename}",
+ targetPattern: "/{lang}/tidbcloud",
+ conditions: { filename: ["_index"] },
+}
+```
+
+**Result**:
+- Source: `/en/tidbcloud/master/tidb-cloud/dedicated/_index.md`
+- Target: `/en/tidbcloud` (or `/tidbcloud` if default language is omitted)
+
+#### 2. Developer Namespace with Folders (Source: `develop`)
+
+```typescript
+{
+ sourcePattern: "/{lang}/tidb/{branch}/{folder}/{...folders}/{filename}",
+ targetPattern: "/{lang}/developer/{filename}",
+ conditions: { folder: ["develop"] },
+ filenameTransform: {
+ ignoreIf: ["_index"],
+ conditionalTarget: {
+ keepIf: ["_index"],
+ keepTargetPattern: "/{lang}/developer/{folders}",
+ },
+ },
+}
+```
+
+**Result**:
+- Source: `/en/tidb/master/develop/subfolder/_index.md`
+- Target: `/en/developer/subfolder`
+- Source: `/en/tidb/master/develop/subfolder/page.md`
+- Target: `/en/developer/page`
+
+#### 3. Branch Aliasing
+
+```typescript
+{
+ sourcePattern: "/{lang}/tidb/{branch}/{...folders}/{filename}",
+ targetPattern: "/{lang}/tidb/{branch:branch-alias-tidb}/{filename}",
+ filenameTransform: {
+ ignoreIf: ["_index", "_docHome"],
+ },
+}
+```
+
+With alias configuration:
+```typescript
+aliases: {
+ "branch-alias-tidb": {
+ mappings: {
+ master: "dev",
+ "release-*": "v*", // release-8.5 -> v8.5
+ },
+ },
+}
+```
+
+**Result**:
+- Source: `/en/tidb/master/alert-rules.md`
+- Target: `/en/tidb/dev/alert-rules`
+- Source: `/en/tidb/release-8.5/alert-rules.md`
+- Target: `/en/tidb/v8.5/alert-rules`
+
+## API Reference
+
+### `parseSourcePath(absolutePath, sourceBasePath)`
+
+Parses a source file path into segments and filename.
+
+**Parameters**:
+- `absolutePath`: Absolute path to source file or slug format (e.g., `"en/tidb/master/alert-rules"`)
+- `sourceBasePath`: Base path for source files
+
+**Returns**: `ParsedSourcePath | null`
+
+```typescript
+interface ParsedSourcePath {
+ segments: string[]; // ["en", "tidb", "master", "alert-rules.md"]
+ filename: string; // "alert-rules"
+}
+```
+
+### `calculateFileUrl(absolutePath, omitDefaultLanguage?)`
+
+Calculates the published URL for a source file path.
+
+**Parameters**:
+- `absolutePath`: Absolute path to source file or slug format
+- `omitDefaultLanguage`: Whether to omit default language prefix (default: `false`)
+
+**Returns**: `string | null` - The resolved URL or `null` if no rule matches
+
+### `calculateFileUrlWithConfig(absolutePath, config, omitDefaultLanguage?)`
+
+Internal implementation that accepts a custom configuration object.
+
+### `clearUrlResolverCache()`
+
+Clears all caches (useful for testing or when configuration changes).
+
+## How It Works
+
+1. **Parse Source Path**: The resolver first parses the input path (absolute or slug format) into segments and filename.
+
+2. **Pattern Matching**: It iterates through `pathMappings` rules in order, trying to match the `sourcePattern` against the parsed segments.
+
+3. **Variable Extraction**: When a pattern matches, variables are extracted (e.g., `lang`, `repo`, `branch`, `filename`).
+
+4. **Condition Checking**: If the rule has `conditions`, they are checked against the extracted variables.
+
+5. **Filename Transform**: If `filenameTransform` is specified, the filename may be ignored or an alternative target pattern may be used.
+
+6. **Alias Application**: If the target pattern contains alias syntax (e.g., `{branch:branch-alias-tidb}`), the alias is applied.
+
+7. **URL Construction**: The target URL is built by applying the target pattern with the variables.
+
+8. **Post-processing**:
+ - Default language omission (if `omitDefaultLanguage` is `true`)
+ - Trailing slash handling (based on `trailingSlash` config)
+
+9. **Fallback**: If no rule matches, a default fallback rule is used.
+
+## Examples
+
+### Example 1: Basic TiDB Page
+
+```typescript
+// Input: "en/tidb/master/alert-rules.md"
+// Rule matches: /{lang}/tidb/{branch}/{...folders}/{filename}
+// Variables: { lang: "en", repo: "tidb", branch: "master", filename: "alert-rules" }
+// Alias applied: master -> dev (via branch-alias-tidb)
+// Target: /{lang}/tidb/{branch}/{filename} = /en/tidb/dev/alert-rules
+// omitDefaultLanguage=true: /tidb/dev/alert-rules
+```
+
+### Example 2: Developer Namespace Index (Source: `develop`)
+
+```typescript
+// Input: "en/tidb/release-8.5/develop/subfolder/_index.md"
+// Rule matches: /{lang}/tidb/{branch}/{folder}/{...folders}/{filename}
+// with condition: folder = "develop"
+// Variables: { lang: "en", repo: "tidb", branch: "release-8.5", folder: "develop", folders: ["subfolder"], filename: "_index" }
+// Filename transform: _index matches keepIf, use keepTargetPattern
+// Target: /{lang}/developer/{folders} = /en/developer/subfolder
+// omitDefaultLanguage=true: /developer/subfolder
+```
+
+### Example 3: TiDBCloud with Prefix
+
+```typescript
+// Input: "en/tidbcloud/master/tidb-cloud/dedicated/starter/_index.md"
+// Rule matches: /{lang}/tidbcloud/{branch}/tidb-cloud/{...prefixes}/{filename}
+// Variables: { lang: "en", repo: "tidbcloud", branch: "master", prefixes: ["dedicated", "starter"], filename: "_index" }
+// Filename transform: _index matches keepIf, use keepTargetPattern
+// Target: /{lang}/tidbcloud/{prefixes} = /en/tidbcloud/dedicated/starter
+// omitDefaultLanguage=true: /tidbcloud/dedicated/starter
+```
+
+## Best Practices
+
+1. **Rule Order**: Place more specific rules before general ones, as the first match wins.
+
+2. **Conditions**: Use conditions to narrow down when a rule should apply, avoiding conflicts.
+
+3. **Filename Transforms**: Use `filenameTransform` to handle special cases like `_index` files.
+
+4. **Aliases**: Use aliases for branch name transformations to keep rules clean and maintainable.
+
+5. **Testing**: Always test URL resolution with various input formats (absolute paths, slugs, with/without extensions).
+
+6. **Cache Clearing**: Clear caches when configuration changes during development.
+
+## Troubleshooting
+
+### URL is `null`
+
+- Check if the source path matches any `sourcePattern` in the configuration
+- Verify the path format (absolute path or valid slug format)
+- Ensure the path has at least 2 segments (lang + filename or more)
+
+### Wrong URL Generated
+
+- Check rule order - earlier rules take precedence
+- Verify conditions are correctly specified
+- Check if filename transforms are applied correctly
+- Ensure aliases are properly configured
+
+### Default Language Not Omitted
+
+- Ensure `omitDefaultLanguage` parameter is set to `true`
+- Verify `defaultLanguage` is set in configuration
+- Check that the URL starts with `/{defaultLanguage}/`
diff --git a/gatsby/url-resolver/__tests__/README.md b/gatsby/url-resolver/__tests__/README.md
new file mode 100644
index 000000000..a35c4e929
--- /dev/null
+++ b/gatsby/url-resolver/__tests__/README.md
@@ -0,0 +1,65 @@
+# URL Resolver Tests
+
+This directory contains test cases for the URL resolver module.
+
+## Test Files
+
+- **pattern-matcher.test.ts**: Tests for pattern matching functionality
+ - Pattern matching with variables
+ - Variable segments (0 or N segments)
+ - Pattern application with aliases
+
+- **branch-alias.test.ts**: Tests for alias functionality
+ - Exact match aliases
+ - Wildcard pattern aliases
+ - Regex pattern aliases
+ - Context-based alias selection
+
+- **url-resolver.test.ts**: Tests for URL resolver main functionality
+ - Source path parsing
+ - URL calculation with different mapping rules
+ - Conditional target patterns
+ - Branch aliasing
+
+## Running Tests
+
+To run all tests:
+
+```bash
+yarn test
+```
+
+To run tests for a specific file:
+
+```bash
+yarn test pattern-matcher
+yarn test branch-alias
+yarn test url-resolver
+```
+
+## Test Coverage
+
+The tests cover:
+
+1. **Pattern Matching**
+ - Simple variable matching
+ - Variable segments (0 or more)
+ - Complex patterns with multiple variables
+
+2. **Pattern Application**
+ - Variable substitution
+ - Empty variable handling
+ - Alias syntax with context
+
+3. **Alias Resolution**
+ - Exact matches
+ - Wildcard patterns (`release-*` -> `v*`)
+ - Regex patterns
+ - Context-based filtering
+
+4. **URL Resolution**
+ - tidbcloud with prefix mapping
+ - develop/best-practices/api/releases mapping
+ - tidb with branch aliasing
+ - Fallback rules
+ - Conditional target patterns (for `_index` files)
diff --git a/gatsby/url-resolver/__tests__/branch-alias.test.ts b/gatsby/url-resolver/__tests__/branch-alias.test.ts
new file mode 100644
index 000000000..07fdc559b
--- /dev/null
+++ b/gatsby/url-resolver/__tests__/branch-alias.test.ts
@@ -0,0 +1,168 @@
+/**
+ * Tests for branch-alias.ts
+ */
+
+import { getAlias, getVariableAlias } from "../branch-alias";
+import type { AliasMapping, UrlResolverConfig } from "../types";
+
+describe("getAlias", () => {
+ it("should return exact match", () => {
+ const mappings: AliasMapping = {
+ master: "stable",
+ main: "stable",
+ };
+ expect(getAlias(mappings, "master")).toBe("stable");
+ expect(getAlias(mappings, "main")).toBe("stable");
+ });
+
+ it("should return null for non-existent key", () => {
+ const mappings: AliasMapping = {
+ master: "stable",
+ };
+ expect(getAlias(mappings, "unknown")).toBeNull();
+ });
+
+ it("should handle wildcard pattern matching", () => {
+ const mappings: AliasMapping = {
+ "release-*": "v*",
+ };
+ expect(getAlias(mappings, "release-8.5")).toBe("v8.5");
+ expect(getAlias(mappings, "release-8.1")).toBe("v8.1");
+ expect(getAlias(mappings, "release-7.5")).toBe("v7.5");
+ });
+
+ it("should handle multiple wildcards in pattern", () => {
+ const mappings: AliasMapping = {
+ "release-*-*": "v*-*",
+ };
+ expect(getAlias(mappings, "release-8-5")).toBe("v8-5");
+ });
+
+ it("should handle regex pattern matching", () => {
+ const mappings: AliasMapping = {
+ pattern: {
+ pattern: "release-(.*)",
+ replacement: "v$1",
+ useRegex: true,
+ },
+ };
+ expect(getAlias(mappings, "release-8.5")).toBe("v8.5");
+ expect(getAlias(mappings, "release-8.1")).toBe("v8.1");
+ });
+
+ it("should prioritize exact match over pattern match", () => {
+ const mappings: AliasMapping = {
+ "release-8.5": "v8.5-specific",
+ "release-*": "v*",
+ };
+ expect(getAlias(mappings, "release-8.5")).toBe("v8.5-specific");
+ expect(getAlias(mappings, "release-8.1")).toBe("v8.1");
+ });
+
+ it("should return null for non-matching pattern", () => {
+ const mappings: AliasMapping = {
+ "release-*": "v*",
+ };
+ expect(getAlias(mappings, "master")).toBeNull();
+ });
+});
+
+describe("getVariableAlias", () => {
+ it("should return alias when context matches", () => {
+ const config: Partial = {
+ aliases: {
+ "branch-alias": {
+ context: {
+ repo: ["tidb", "tidb-in-kubernetes"],
+ },
+ mappings: {
+ master: "stable",
+ },
+ },
+ },
+ };
+ const contextVariables = {
+ repo: "tidb",
+ };
+ expect(
+ getVariableAlias("branch-alias", "master", config, contextVariables)
+ ).toBe("stable");
+ });
+
+ it("should return null when context doesn't match", () => {
+ const config: Partial = {
+ aliases: {
+ "branch-alias": {
+ context: {
+ repo: ["tidb", "tidb-in-kubernetes"],
+ },
+ mappings: {
+ master: "stable",
+ },
+ },
+ },
+ };
+ const contextVariables = {
+ repo: "other-repo",
+ };
+ expect(
+ getVariableAlias("branch-alias", "master", config, contextVariables)
+ ).toBeNull();
+ });
+
+ it("should return alias when no context is specified", () => {
+ const config: Partial = {
+ aliases: {
+ "branch-alias": {
+ mappings: {
+ master: "stable",
+ },
+ },
+ },
+ };
+ const contextVariables = {};
+ expect(
+ getVariableAlias("branch-alias", "master", config, contextVariables)
+ ).toBe("stable");
+ });
+
+ it("should handle multiple context conditions", () => {
+ const config: Partial = {
+ aliases: {
+ "branch-alias": {
+ context: {
+ repo: ["tidb"],
+ lang: ["en"],
+ },
+ mappings: {
+ master: "stable",
+ },
+ },
+ },
+ };
+ const contextVariables1 = {
+ repo: "tidb",
+ lang: "en",
+ };
+ const contextVariables2 = {
+ repo: "tidb",
+ lang: "zh",
+ };
+ expect(
+ getVariableAlias("branch-alias", "master", config, contextVariables1)
+ ).toBe("stable");
+ expect(
+ getVariableAlias("branch-alias", "master", config, contextVariables2)
+ ).toBeNull();
+ });
+
+ it("should return null for non-existent alias name", () => {
+ const config: Partial = {
+ aliases: {},
+ };
+ const contextVariables = {};
+ expect(
+ getVariableAlias("non-existent", "master", config, contextVariables)
+ ).toBeNull();
+ });
+});
diff --git a/gatsby/url-resolver/__tests__/pattern-matcher.test.ts b/gatsby/url-resolver/__tests__/pattern-matcher.test.ts
new file mode 100644
index 000000000..adc63ed96
--- /dev/null
+++ b/gatsby/url-resolver/__tests__/pattern-matcher.test.ts
@@ -0,0 +1,232 @@
+/**
+ * Tests for pattern-matcher.ts
+ */
+
+import { matchPattern, applyPattern } from "../pattern-matcher";
+import type { UrlResolverConfig } from "../types";
+
+describe("matchPattern", () => {
+ it("should match simple pattern with variables", () => {
+ const pattern = "/{lang}/{repo}/{filename}";
+ const segments = ["en", "tidb", "alert-rules"];
+ const result = matchPattern(pattern, segments);
+ expect(result).toEqual({
+ lang: "en",
+ repo: "tidb",
+ filename: "alert-rules",
+ });
+ });
+
+ it("should match pattern with variable segments (0 segments)", () => {
+ const pattern = "/{lang}/{repo}/{...folders}/{filename}";
+ const segments = ["en", "tidb", "alert-rules"];
+ const result = matchPattern(pattern, segments);
+ expect(result).toEqual({
+ lang: "en",
+ repo: "tidb",
+ folders: "",
+ filename: "alert-rules",
+ });
+ });
+
+ it("should match pattern with variable segments (1 segment)", () => {
+ const pattern = "/{lang}/{repo}/{...folders}/{filename}";
+ const segments = ["en", "tidb", "subfolder", "alert-rules"];
+ const result = matchPattern(pattern, segments);
+ expect(result).toEqual({
+ lang: "en",
+ repo: "tidb",
+ folders: "subfolder",
+ filename: "alert-rules",
+ });
+ });
+
+ it("should match pattern with variable segments (multiple segments)", () => {
+ const pattern = "/{lang}/{repo}/{...folders}/{filename}";
+ const segments = [
+ "en",
+ "tidb",
+ "folder1",
+ "folder2",
+ "folder3",
+ "alert-rules",
+ ];
+ const result = matchPattern(pattern, segments);
+ expect(result).toEqual({
+ lang: "en",
+ repo: "tidb",
+ folders: "folder1/folder2/folder3",
+ filename: "alert-rules",
+ });
+ });
+
+ it("should match pattern with variable segments at the end", () => {
+ const pattern = "/{lang}/{repo}/{namespace}/{...prefixes}/{filename}";
+ const segments = [
+ "en",
+ "tidbcloud",
+ "tidb-cloud",
+ "dedicated",
+ "starter",
+ "_index",
+ ];
+ const result = matchPattern(pattern, segments);
+ expect(result).toEqual({
+ lang: "en",
+ repo: "tidbcloud",
+ namespace: "tidb-cloud",
+ prefixes: "dedicated/starter",
+ filename: "_index",
+ });
+ });
+
+ it("should return null for non-matching pattern", () => {
+ const pattern = "/{lang}/{repo}/{filename}";
+ const segments = ["en", "tidb", "folder", "alert-rules"];
+ const result = matchPattern(pattern, segments);
+ expect(result).toBeNull();
+ });
+
+ it("should match complex pattern with conditions", () => {
+ const pattern =
+ "/{lang}/{repo}/{branch}/{namespace}/{...prefixes}/{filename}";
+ const segments = [
+ "en",
+ "tidbcloud",
+ "master",
+ "tidb-cloud",
+ "dedicated",
+ "_index",
+ ];
+ const result = matchPattern(pattern, segments);
+ expect(result).toEqual({
+ lang: "en",
+ repo: "tidbcloud",
+ branch: "master",
+ namespace: "tidb-cloud",
+ prefixes: "dedicated",
+ filename: "_index",
+ });
+ });
+});
+
+describe("applyPattern", () => {
+ it("should apply simple pattern with variables", () => {
+ const pattern = "/{lang}/{repo}/{filename}";
+ const variables = {
+ lang: "en",
+ repo: "tidb",
+ filename: "alert-rules",
+ };
+ const result = applyPattern(pattern, variables);
+ expect(result).toBe("/en/tidb/alert-rules");
+ });
+
+ it("should skip empty variables (from 0-match variable segments)", () => {
+ const pattern = "/{lang}/{repo}/{folders}/{filename}";
+ const variables = {
+ lang: "en",
+ repo: "tidb",
+ folders: "",
+ filename: "alert-rules",
+ };
+ const result = applyPattern(pattern, variables);
+ expect(result).toBe("/en/tidb/alert-rules");
+ });
+
+ it("should expand variable segments with slashes", () => {
+ const pattern = "/{lang}/{repo}/{folders}/{filename}";
+ const variables = {
+ lang: "en",
+ repo: "tidb",
+ folders: "folder1/folder2/folder3",
+ filename: "alert-rules",
+ };
+ const result = applyPattern(pattern, variables);
+ expect(result).toBe("/en/tidb/folder1/folder2/folder3/alert-rules");
+ });
+
+ it("should apply alias syntax with context", () => {
+ const pattern = "/{lang}/{repo}/{branch:branch-alias}/{filename}";
+ const variables = {
+ lang: "en",
+ repo: "tidb",
+ branch: "master",
+ filename: "alert-rules",
+ };
+ const config: Partial = {
+ aliases: {
+ "branch-alias": {
+ context: {
+ repo: ["tidb", "tidb-in-kubernetes"],
+ },
+ mappings: {
+ master: "stable",
+ },
+ },
+ },
+ };
+ const result = applyPattern(pattern, variables, config);
+ expect(result).toBe("/en/tidb/stable/alert-rules");
+ });
+
+ it("should not apply alias when context doesn't match", () => {
+ const pattern = "/{lang}/{repo}/{branch:branch-alias}/{filename}";
+ const variables = {
+ lang: "en",
+ repo: "other-repo",
+ branch: "master",
+ filename: "alert-rules",
+ };
+ const config: Partial = {
+ aliases: {
+ "branch-alias": {
+ context: {
+ repo: ["tidb", "tidb-in-kubernetes"],
+ },
+ mappings: {
+ master: "stable",
+ },
+ },
+ },
+ };
+ const result = applyPattern(pattern, variables, config);
+ expect(result).toBe("/en/other-repo/master/alert-rules");
+ });
+
+ it("should handle wildcard alias patterns", () => {
+ const pattern = "/{lang}/{repo}/{branch:branch-alias}/{filename}";
+ const variables = {
+ lang: "en",
+ repo: "tidb",
+ branch: "release-8.5",
+ filename: "alert-rules",
+ };
+ const config: Partial = {
+ aliases: {
+ "branch-alias": {
+ context: {
+ repo: ["tidb"],
+ },
+ mappings: {
+ "release-*": "v*",
+ },
+ },
+ },
+ };
+ const result = applyPattern(pattern, variables, config);
+ expect(result).toBe("/en/tidb/v8.5/alert-rules");
+ });
+
+ it("should handle empty folders variable", () => {
+ const pattern = "/{lang}/{repo}/{folders}/{filename}";
+ const variables = {
+ lang: "en",
+ repo: "tidb",
+ folders: "",
+ filename: "alert-rules",
+ };
+ const result = applyPattern(pattern, variables);
+ expect(result).toBe("/en/tidb/alert-rules");
+ });
+});
diff --git a/gatsby/url-resolver/__tests__/url-resolver.test.ts b/gatsby/url-resolver/__tests__/url-resolver.test.ts
new file mode 100644
index 000000000..09f72c6b1
--- /dev/null
+++ b/gatsby/url-resolver/__tests__/url-resolver.test.ts
@@ -0,0 +1,643 @@
+/**
+ * Tests for url-resolver.ts
+ */
+
+import { parseSourcePath, calculateFileUrlWithConfig } from "../url-resolver";
+import type { UrlResolverConfig } from "../types";
+import { defaultUrlResolverConfig } from "../config";
+import path from "path";
+
+describe("parseSourcePath", () => {
+ const sourceBasePath = "/base/path/docs/markdown-pages";
+
+ it("should parse valid source path", () => {
+ const absolutePath =
+ "/base/path/docs/markdown-pages/en/tidb/master/alert-rules.md";
+ const result = parseSourcePath(absolutePath, sourceBasePath);
+ expect(result).toEqual({
+ segments: ["en", "tidb", "master", "alert-rules.md"],
+ filename: "alert-rules",
+ });
+ });
+
+ it("should handle _index.md files", () => {
+ const absolutePath =
+ "/base/path/docs/markdown-pages/en/tidbcloud/master/tidb-cloud/dedicated/_index.md";
+ const result = parseSourcePath(absolutePath, sourceBasePath);
+ expect(result).toEqual({
+ segments: [
+ "en",
+ "tidbcloud",
+ "master",
+ "tidb-cloud",
+ "dedicated",
+ "_index.md",
+ ],
+ filename: "_index",
+ });
+ });
+
+ it("should handle relative path (slug format) without .md extension", () => {
+ const slug = "en/tidb/master/alert-rules";
+ const result = parseSourcePath(slug, sourceBasePath);
+ expect(result).toEqual({
+ segments: ["en", "tidb", "master", "alert-rules.md"],
+ filename: "alert-rules",
+ });
+ });
+
+ it("should handle relative path (slug format) with .md extension", () => {
+ const slug = "en/tidb/master/alert-rules.md";
+ const result = parseSourcePath(slug, sourceBasePath);
+ expect(result).toEqual({
+ segments: ["en", "tidb", "master", "alert-rules.md"],
+ filename: "alert-rules",
+ });
+ });
+
+ it("should handle relative path (slug format) with leading slash", () => {
+ const slug = "/en/tidb/master/alert-rules";
+ const result = parseSourcePath(slug, sourceBasePath);
+ expect(result).toEqual({
+ segments: ["en", "tidb", "master", "alert-rules.md"],
+ filename: "alert-rules",
+ });
+ });
+
+ it("should handle relative path for tidbcloud", () => {
+ const slug = "en/tidbcloud/master/tidb-cloud/dedicated/_index";
+ const result = parseSourcePath(slug, sourceBasePath);
+ expect(result).toEqual({
+ segments: [
+ "en",
+ "tidbcloud",
+ "master",
+ "tidb-cloud",
+ "dedicated",
+ "_index.md",
+ ],
+ filename: "_index",
+ });
+ });
+
+ it("should return null for path with too few segments", () => {
+ const absolutePath = "/base/path/docs/markdown-pages/en.md";
+ const result = parseSourcePath(absolutePath, sourceBasePath);
+ expect(result).toBeNull();
+ });
+
+ it("should handle paths with trailing slashes", () => {
+ const absolutePath =
+ "/base/path/docs/markdown-pages/en/tidb/master/alert-rules.md/";
+ const result = parseSourcePath(absolutePath, sourceBasePath);
+ expect(result).toEqual({
+ segments: ["en", "tidb", "master", "alert-rules.md"],
+ filename: "alert-rules",
+ });
+ });
+});
+
+describe("calculateFileUrl", () => {
+ const sourceBasePath = path.resolve(
+ __dirname,
+ "../../../docs/markdown-pages"
+ );
+
+ const testConfig: UrlResolverConfig = {
+ ...defaultUrlResolverConfig,
+ sourceBasePath,
+ // Test config: don't omit default language, use auto trailing slash
+ defaultLanguage: undefined,
+ trailingSlash: "auto",
+ };
+
+ it("should resolve tidbcloud dedicated _index to /tidbcloud (first rule)", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "en/tidbcloud/master/tidb-cloud/dedicated/_index.md"
+ );
+ const url = calculateFileUrlWithConfig(absolutePath, testConfig);
+ // First rule matches: /{lang}/tidbcloud/master/tidb-cloud/dedicated/{filename}
+ // with condition filename = "_index" -> /{lang}/tidbcloud
+ // trailingSlash: "auto" adds trailing slash for _index files
+ expect(url).toBe("/en/tidbcloud/");
+ });
+
+ it("should resolve tidbcloud _index with prefixes (second rule)", () => {
+ // This test verifies the second rule for paths with multiple prefixes
+ const absolutePath = path.join(
+ sourceBasePath,
+ "en/tidbcloud/master/tidb-cloud/dedicated/starter/_index.md"
+ );
+ const url = calculateFileUrlWithConfig(absolutePath, testConfig);
+ // Second rule matches: /{lang}/tidbcloud/{branch}/tidb-cloud/{...prefixes}/{filename}
+ // with conditionalTarget for _index -> /{lang}/tidbcloud/{prefixes}
+ expect(url).toBe("/en/tidbcloud/dedicated/starter");
+ });
+
+ it("should resolve tidbcloud non-index without prefixes", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "en/tidbcloud/master/tidb-cloud/dedicated/some-page.md"
+ );
+ const url = calculateFileUrlWithConfig(absolutePath, testConfig);
+ expect(url).toBe("/en/tidbcloud/some-page/");
+ });
+
+ it("should resolve tidbcloud with multiple prefixes for _index", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "en/tidbcloud/master/tidb-cloud/dedicated/starter/_index.md"
+ );
+ const url = calculateFileUrlWithConfig(absolutePath, testConfig);
+ expect(url).toBe("/en/tidbcloud/dedicated/starter");
+ });
+
+ it("should resolve develop _index with folders", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "en/tidb/release-8.5/develop/subfolder/_index.md"
+ );
+ const url = calculateFileUrlWithConfig(absolutePath, testConfig);
+ expect(url).toBe("/en/developer/subfolder");
+ });
+
+ it("should resolve develop non-index without folders", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "en/tidb/release-8.5/develop/subfolder/some-page.md"
+ );
+ const url = calculateFileUrlWithConfig(absolutePath, testConfig);
+ expect(url).toBe("/en/developer/some-page/");
+ });
+
+ it("should resolve tidb with branch alias (master -> dev)", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "en/tidb/master/alert-rules.md"
+ );
+ const url = calculateFileUrlWithConfig(absolutePath, testConfig);
+ expect(url).toBe("/en/tidb/dev/alert-rules/");
+ });
+
+ it("should resolve tidb with branch alias (release-8.5 -> stable)", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "en/tidb/release-8.5/alert-rules.md"
+ );
+ const url = calculateFileUrlWithConfig(absolutePath, testConfig);
+ // release-8.5 -> stable via branch-alias-tidb (exact match takes precedence)
+ expect(url).toBe("/en/tidb/stable/alert-rules/");
+ });
+
+ it("should resolve tidb _index with branch alias", () => {
+ const absolutePath = path.join(sourceBasePath, "en/tidb/master/_index.md");
+ const url = calculateFileUrlWithConfig(absolutePath, testConfig);
+ expect(url).toBe("/en/tidb/dev");
+ });
+
+ it("should resolve tidb with folders and branch alias", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "en/tidb/master/subfolder/page.md"
+ );
+ const url = calculateFileUrlWithConfig(absolutePath, testConfig);
+ expect(url).toBe("/en/tidb/dev/page/");
+ });
+
+ it("should resolve api folder", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "en/tidb/release-8.5/api/overview.md"
+ );
+ const url = calculateFileUrlWithConfig(absolutePath, testConfig);
+ expect(url).toBe("/en/api/overview/");
+ });
+
+ it("should resolve best-practices folder", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "en/tidb/release-8.5/best-practices/guide.md"
+ );
+ const url = calculateFileUrlWithConfig(absolutePath, testConfig);
+ expect(url).toBe("/en/best-practices/guide/");
+ });
+
+ it("should resolve ai folder", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "en/tidb/release-8.5/ai/overview.md"
+ );
+ const url = calculateFileUrlWithConfig(absolutePath, testConfig);
+ expect(url).toBe("/en/ai/overview/");
+ });
+
+ it("should resolve ai _index with folders", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "en/tidb/release-8.5/ai/subfolder/_index.md"
+ );
+ const url = calculateFileUrlWithConfig(absolutePath, testConfig);
+ expect(url).toBe("/en/ai/subfolder");
+ });
+
+ it("should resolve releases folder", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "en/tidb/release-8.5/releases/_index.md"
+ );
+ const url = calculateFileUrlWithConfig(absolutePath, testConfig);
+ // Matches rule: /{lang}/tidb/release-8.5/releases/{filename} -> /{lang}/releases/tidb
+ // trailingSlash: "auto" adds trailing slash for _index files
+ expect(url).toBe("/en/releases/tidb-self-managed/");
+ });
+
+ it("should resolve releases folder zh", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "zh/tidb/release-8.5/releases/_index.md"
+ );
+ const url = calculateFileUrlWithConfig(absolutePath, testConfig);
+ // Matches rule: /{lang}/tidb/release-8.5/releases/{filename} -> /{lang}/releases/tidb
+ // trailingSlash: "auto" adds trailing slash for _index files
+ expect(url).toBe("/zh/releases/tidb-self-managed/");
+ });
+
+ it("should resolve tidbcloud releases folder", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "en/tidbcloud/master/tidb-cloud/releases/_index.md"
+ );
+ const url = calculateFileUrlWithConfig(absolutePath, testConfig);
+ // Matches rule: /{lang}/tidbcloud/master/tidb-cloud/releases/{filename} -> /{lang}/releases/tidb-cloud
+ // trailingSlash: "auto" adds trailing slash for _index files
+ expect(url).toBe("/en/releases/tidb-cloud/");
+ });
+
+ it("should resolve tidbcloud releases folder zh", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "zh/tidbcloud/master/tidb-cloud/releases/_index.md"
+ );
+ const url = calculateFileUrlWithConfig(absolutePath, testConfig);
+ // Matches rule: /{lang}/tidbcloud/master/tidb-cloud/releases/{filename} -> /{lang}/releases/tidb-cloud
+ // trailingSlash: "auto" adds trailing slash for _index files
+ expect(url).toBe("/zh/releases/tidb-cloud/");
+ });
+
+ it("should use fallback rule for unmatched patterns", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "en/other-repo/some-folder/page.md"
+ );
+ const url = calculateFileUrlWithConfig(absolutePath, testConfig);
+ expect(url).toBe("/en/other-repo/page/");
+ });
+
+ it("should handle fallback with _index", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "en/other-repo/some-folder/_index.md"
+ );
+ const url = calculateFileUrlWithConfig(absolutePath, testConfig);
+ expect(url).toBe("/en/other-repo");
+ });
+
+ it("should handle nested fallback with _index at repo root", () => {
+ const absolutePath = path.join(sourceBasePath, "en/other-repo/_index.md");
+ const url = calculateFileUrlWithConfig(absolutePath, testConfig);
+ expect(url).toBe("/en/other-repo");
+ });
+
+ it("should return null for invalid path", () => {
+ const absolutePath = "/invalid/path/file.md";
+ const url = calculateFileUrlWithConfig(absolutePath, testConfig);
+ expect(url).toBeNull();
+ });
+
+ it("should resolve tidb with release-8.5 branch alias (release-8.5 -> stable)", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "en/tidb/release-8.5/alert-rules.md"
+ );
+ const url = calculateFileUrlWithConfig(absolutePath, testConfig);
+ // release-8.5 -> stable via branch-alias-tidb (exact match)
+ expect(url).toBe("/en/tidb/stable/alert-rules/");
+ });
+});
+
+describe("calculateFileUrl with defaultLanguage: 'en'", () => {
+ const sourceBasePath = path.resolve(
+ __dirname,
+ "../../../docs/markdown-pages"
+ );
+
+ const configWithDefaultLang: UrlResolverConfig = {
+ ...defaultUrlResolverConfig,
+ sourceBasePath,
+ defaultLanguage: "en",
+ trailingSlash: "never",
+ };
+
+ it("should omit /en/ prefix for English files (tidb)", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "en/tidb/master/alert-rules.md"
+ );
+ const url = calculateFileUrlWithConfig(
+ absolutePath,
+ configWithDefaultLang,
+ true
+ );
+ // master -> dev via branch-alias-tidb
+ expect(url).toBe("/tidb/dev/alert-rules");
+ });
+
+ it("should omit /en/ prefix for English files (tidbcloud)", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "en/tidbcloud/master/tidb-cloud/dedicated/some-page.md"
+ );
+ const url = calculateFileUrlWithConfig(
+ absolutePath,
+ configWithDefaultLang,
+ true
+ );
+ expect(url).toBe("/tidbcloud/some-page");
+ });
+
+ it("should omit /en/ prefix for English dedicated _index files (tidbcloud)", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "en/tidbcloud/master/tidb-cloud/dedicated/_index.md"
+ );
+ const url = calculateFileUrlWithConfig(
+ absolutePath,
+ configWithDefaultLang,
+ true
+ );
+ // First rule matches: /{lang}/tidbcloud/master/tidb-cloud/dedicated/{filename}
+ // with condition filename = "_index" -> /{lang}/tidbcloud
+ // After defaultLanguage omission: /tidbcloud
+ // trailingSlash: "never" removes trailing slash
+ expect(url).toBe("/tidbcloud");
+ });
+
+ it("should omit /en/ prefix for English develop files", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "en/tidb/release-8.5/develop/overview.md"
+ );
+ const url = calculateFileUrlWithConfig(
+ absolutePath,
+ configWithDefaultLang,
+ true
+ );
+ expect(url).toBe("/developer/overview");
+ });
+
+ it("should keep /zh/ prefix for Chinese files", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "zh/tidb/master/alert-rules.md"
+ );
+ const url = calculateFileUrlWithConfig(absolutePath, configWithDefaultLang);
+ // master -> dev via branch-alias-tidb
+ expect(url).toBe("/zh/tidb/dev/alert-rules");
+ });
+
+ it("should keep /ja/ prefix for Japanese files", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "ja/tidb/release-8.5/alert-rules.md"
+ );
+ const url = calculateFileUrlWithConfig(absolutePath, configWithDefaultLang);
+ // release-8.5 -> stable via branch-alias-tidb (exact match)
+ expect(url).toBe("/ja/tidb/stable/alert-rules");
+ });
+
+ it("should omit /en/ prefix for English api files", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "en/tidb/release-8.5/api/overview.md"
+ );
+ const url = calculateFileUrlWithConfig(
+ absolutePath,
+ configWithDefaultLang,
+ true
+ );
+ expect(url).toBe("/api/overview");
+ });
+
+ it("should omit /en/ prefix for English release branch files", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "en/tidb/release-8.5/alert-rules.md"
+ );
+ const url = calculateFileUrlWithConfig(
+ absolutePath,
+ configWithDefaultLang,
+ true
+ );
+ // release-8.5 -> stable via branch-alias-tidb (exact match takes precedence)
+ expect(url).toBe("/tidb/stable/alert-rules");
+ });
+});
+
+describe("calculateFileUrl with slug format (relative path)", () => {
+ const sourceBasePath = path.resolve(
+ __dirname,
+ "../../../docs/markdown-pages"
+ );
+
+ const configWithDefaultLang: UrlResolverConfig = {
+ sourceBasePath,
+ defaultLanguage: "en",
+ trailingSlash: "never",
+ pathMappings: [
+ // tidbcloud with prefix
+ {
+ sourcePattern:
+ "/{lang}/{repo}/{branch}/{namespace}/{...prefixes}/{filename}",
+ targetPattern: "/{lang}/{repo}/{filename}",
+ conditions: {
+ repo: ["tidbcloud"],
+ namespace: ["tidb-cloud"],
+ },
+ filenameTransform: {
+ ignoreIf: ["_index"],
+ conditionalTarget: {
+ keepIf: ["_index"],
+ keepTargetPattern: "/{lang}/{repo}/{prefixes}",
+ },
+ },
+ },
+ // tidb with branch
+ {
+ sourcePattern: "/{lang}/{repo}/{branch}/{...folders}/{filename}",
+ targetPattern: "/{lang}/{repo}/{branch:branch-alias-tidb}/{filename}",
+ conditions: {
+ repo: ["tidb"],
+ },
+ filenameTransform: {
+ ignoreIf: ["_index", "_docHome"],
+ },
+ },
+ // tidb-in-kubernetes with branch
+ {
+ sourcePattern: "/{lang}/{repo}/{branch}/{...folders}/{filename}",
+ targetPattern:
+ "/{lang}/{repo}/{branch:branch-alias-tidb-in-kubernetes}/{filename}",
+ conditions: {
+ repo: ["tidb-in-kubernetes"],
+ },
+ filenameTransform: {
+ ignoreIf: ["_index", "_docHome"],
+ },
+ },
+ // Fallback
+ {
+ sourcePattern: "/{lang}/{repo}/{...any}/{filename}",
+ targetPattern: "/{lang}/{repo}/{filename}",
+ filenameTransform: {
+ ignoreIf: ["_index", "_docHome"],
+ },
+ },
+ ],
+ aliases: {
+ "branch-alias-tidb": {
+ mappings: {
+ master: "dev",
+ "release-8.5": "stable",
+ "release-*": "v*",
+ },
+ },
+ "branch-alias-tidb-in-kubernetes": {
+ mappings: {
+ main: "dev",
+ "release-1.6": "stable",
+ "release-*": "v*",
+ },
+ },
+ },
+ };
+
+ it("should resolve slug format for tidb files", () => {
+ const slug = "en/tidb/master/alert-rules";
+ const url = calculateFileUrlWithConfig(slug, configWithDefaultLang, true);
+ // master -> dev via branch-alias-tidb
+ expect(url).toBe("/tidb/dev/alert-rules");
+ });
+
+ it("should resolve slug format for tidbcloud files", () => {
+ const slug = "en/tidbcloud/master/tidb-cloud/dedicated/some-page";
+ const url = calculateFileUrlWithConfig(slug, configWithDefaultLang, true);
+ expect(url).toBe("/tidbcloud/some-page");
+ });
+
+ it("should resolve slug format for tidbcloud _index files", () => {
+ const slug = "en/tidbcloud/master/tidb-cloud/dedicated/_index";
+ const url = calculateFileUrlWithConfig(slug, configWithDefaultLang, true);
+ expect(url).toBe("/tidbcloud/dedicated");
+ });
+
+ it("should resolve slug format with leading slash", () => {
+ const slug = "/en/tidb/master/alert-rules";
+ const url = calculateFileUrlWithConfig(slug, configWithDefaultLang, true);
+ // master -> dev via branch-alias-tidb
+ expect(url).toBe("/tidb/dev/alert-rules");
+ });
+
+ it("should resolve slug format for Chinese files", () => {
+ const slug = "zh/tidb/master/alert-rules";
+ const url = calculateFileUrlWithConfig(slug, configWithDefaultLang);
+ // master -> dev via branch-alias-tidb
+ expect(url).toBe("/zh/tidb/dev/alert-rules");
+ });
+
+ it("should return null for invalid slug format", () => {
+ const invalidSlug = "invalid/path";
+ const url = calculateFileUrlWithConfig(invalidSlug, configWithDefaultLang);
+ expect(url).toBeNull();
+ });
+});
+
+describe("calculateFileUrl with omitDefaultLanguage parameter", () => {
+ const sourceBasePath = path.resolve(
+ __dirname,
+ "../../../docs/markdown-pages"
+ );
+
+ const configWithDefaultLang: UrlResolverConfig = {
+ ...defaultUrlResolverConfig,
+ sourceBasePath,
+ defaultLanguage: "en",
+ trailingSlash: "never",
+ };
+
+ it("should keep default language when omitDefaultLanguage is false (default)", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "en/tidb/master/alert-rules.md"
+ );
+ const url = calculateFileUrlWithConfig(absolutePath, configWithDefaultLang);
+ // master -> dev via branch-alias-tidb
+ expect(url).toBe("/en/tidb/dev/alert-rules");
+ });
+
+ it("should keep default language when omitDefaultLanguage is undefined (default)", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "en/tidb/master/alert-rules.md"
+ );
+ const url = calculateFileUrlWithConfig(
+ absolutePath,
+ configWithDefaultLang,
+ false
+ );
+ // master -> dev via branch-alias-tidb
+ expect(url).toBe("/en/tidb/dev/alert-rules");
+ });
+
+ it("should omit default language when omitDefaultLanguage is true", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "en/tidb/master/alert-rules.md"
+ );
+ const url = calculateFileUrlWithConfig(
+ absolutePath,
+ configWithDefaultLang,
+ true
+ );
+ // master -> dev via branch-alias-tidb
+ expect(url).toBe("/tidb/dev/alert-rules");
+ });
+
+ it("should keep non-default language even when omitDefaultLanguage is false", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "zh/tidb/master/alert-rules.md"
+ );
+ const url = calculateFileUrlWithConfig(
+ absolutePath,
+ configWithDefaultLang,
+ false
+ );
+ // master -> dev via branch-alias-tidb
+ expect(url).toBe("/zh/tidb/dev/alert-rules");
+ });
+
+ it("should keep non-default language when omitDefaultLanguage is true", () => {
+ const absolutePath = path.join(
+ sourceBasePath,
+ "zh/tidb/master/alert-rules.md"
+ );
+ const url = calculateFileUrlWithConfig(
+ absolutePath,
+ configWithDefaultLang,
+ true
+ );
+ // master -> dev via branch-alias-tidb
+ expect(url).toBe("/zh/tidb/dev/alert-rules");
+ });
+});
diff --git a/gatsby/url-resolver/branch-alias.ts b/gatsby/url-resolver/branch-alias.ts
new file mode 100644
index 000000000..8762735ac
--- /dev/null
+++ b/gatsby/url-resolver/branch-alias.ts
@@ -0,0 +1,191 @@
+/**
+ * Alias matching utilities (generalized from branch alias)
+ */
+
+import type { AliasMapping, AliasPattern } from "./types";
+
+/**
+ * Convert wildcard pattern to regex
+ * e.g., "release-*" -> /^release-(.+)$/
+ */
+function wildcardToRegex(pattern: string): RegExp {
+ // Escape special regex characters except *
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
+ // Replace * with (.+?) to capture the matched part (non-greedy)
+ const regexPattern = escaped.replace(/\*/g, "(.+?)");
+ return new RegExp(`^${regexPattern}$`);
+}
+
+/**
+ * Apply wildcard replacement
+ * e.g., pattern: "release-*", replacement: "v*", input: "release-8.5" -> "v8.5"
+ */
+function applyWildcardReplacement(
+ pattern: string,
+ replacement: string,
+ input: string
+): string | null {
+ const regex = wildcardToRegex(pattern);
+ const match = input.match(regex);
+ if (!match) {
+ return null;
+ }
+
+ const replacementWildcardCount = (replacement.match(/\*/g) || []).length;
+
+ // Replace * in replacement with captured groups
+ let result = replacement;
+ let replacementIndex = 0;
+ for (
+ let i = 1;
+ i < match.length && replacementIndex < replacementWildcardCount;
+ i++
+ ) {
+ // Replace the first * with the captured group
+ result = result.replace("*", match[i]);
+ replacementIndex++;
+ }
+
+ return result;
+}
+
+/**
+ * Apply regex replacement
+ * e.g., pattern: "release-(.*)", replacement: "v$1", input: "release-8.5" -> "v8.5"
+ */
+function applyRegexReplacement(
+ pattern: string,
+ replacement: string,
+ input: string
+): string | null {
+ try {
+ const regex = new RegExp(pattern);
+ const match = input.match(regex);
+ if (!match) {
+ return null;
+ }
+
+ // Replace $1, $2, etc. with captured groups
+ let result = replacement;
+ for (let i = 1; i < match.length; i++) {
+ result = result.replace(new RegExp(`\\$${i}`, "g"), match[i]);
+ }
+
+ return result;
+ } catch (e) {
+ // Invalid regex pattern
+ return null;
+ }
+}
+
+/**
+ * Get alias for a given value
+ * Supports both exact matches and pattern-based matches
+ */
+export function getAlias(
+ aliasMappings: AliasMapping,
+ value: string
+): string | null {
+ // First, try exact match
+ const exactMatch = aliasMappings[value];
+ if (typeof exactMatch === "string") {
+ return exactMatch;
+ }
+
+ // Then, try pattern-based matches
+ // Check each entry in aliasMappings
+ for (const [key, mappingValue] of Object.entries(aliasMappings)) {
+ // Skip if it's an exact match (already checked)
+ if (key === value) {
+ continue;
+ }
+
+ // Check if it's a pattern-based alias
+ if (typeof mappingValue === "object" && mappingValue !== null) {
+ const pattern = mappingValue as AliasPattern;
+ if (pattern.pattern && pattern.replacement) {
+ let result: string | null = null;
+ if (pattern.useRegex) {
+ result = applyRegexReplacement(
+ pattern.pattern,
+ pattern.replacement,
+ value
+ );
+ } else {
+ // Try wildcard matching
+ result = applyWildcardReplacement(
+ pattern.pattern,
+ pattern.replacement,
+ value
+ );
+ }
+ if (result) {
+ return result;
+ }
+ }
+ } else if (typeof mappingValue === "string") {
+ // Check if key is a wildcard pattern
+ if (key.includes("*")) {
+ const result = applyWildcardReplacement(key, mappingValue, value);
+ if (result) {
+ return result;
+ }
+ }
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Check if context conditions are met
+ */
+function checkContext(
+ context: Record | undefined,
+ variables: Record
+): boolean {
+ if (!context) return true;
+
+ for (const [varName, allowedValues] of Object.entries(context)) {
+ const varValue = variables[varName];
+ if (varValue && allowedValues) {
+ if (!allowedValues.includes(varValue)) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+}
+
+/**
+ * Get alias for a variable value using alias configuration
+ * Supports context-based alias selection
+ */
+export function getVariableAlias(
+ aliasName: string,
+ variableValue: string,
+ config: {
+ aliases?: {
+ [aliasName: string]: {
+ context?: Record;
+ mappings: AliasMapping;
+ };
+ };
+ },
+ contextVariables: Record
+): string | null {
+ if (!config.aliases || !config.aliases[aliasName]) {
+ return null;
+ }
+
+ const aliasConfig = config.aliases[aliasName];
+
+ // Check context conditions if specified
+ if (!checkContext(aliasConfig.context, contextVariables)) {
+ return null;
+ }
+
+ // Get alias from mappings
+ return getAlias(aliasConfig.mappings, variableValue);
+}
diff --git a/gatsby/url-resolver/config.ts b/gatsby/url-resolver/config.ts
new file mode 100644
index 000000000..8b219ef04
--- /dev/null
+++ b/gatsby/url-resolver/config.ts
@@ -0,0 +1,156 @@
+/**
+ * Default URL resolver configuration
+ */
+
+import path from "path";
+import type { UrlResolverConfig } from "./types";
+import CONFIG from "../../docs/docs.json";
+
+export const defaultUrlResolverConfig: UrlResolverConfig = {
+ sourceBasePath: path.resolve(__dirname, "../../docs/markdown-pages"),
+ // Default language (used when omitDefaultLanguage is true)
+ defaultLanguage: "en",
+ // Trailing slash behavior: "never" to match generateUrl behavior
+ trailingSlash: "never",
+
+ pathMappings: [
+ // tidbcloud dedicated _index
+ // /en/tidbcloud/master/tidb-cloud/dedicated/_index.md -> /en/tidbcloud/dedicated/
+ {
+ sourcePattern: "/{lang}/tidbcloud/master/tidb-cloud/dedicated/{filename}",
+ targetPattern: "/{lang}/tidbcloud",
+ conditions: { filename: ["_index"] },
+ },
+ // tidbcloud releases
+ // /en/tidbcloud/master/tidb-cloud/releases/_index.md -> /en/releases/tidb-cloud
+ {
+ sourcePattern: "/{lang}/tidbcloud/master/tidb-cloud/releases/{filename}",
+ targetPattern: "/{lang}/releases/tidb-cloud",
+ conditions: { filename: ["_index"] },
+ },
+ // tidb releases
+ // /en/tidb/master/releases/_index.md -> /en/releases/tidb-self-managed
+ {
+ sourcePattern: `/{lang}/tidb/${CONFIG.docs.tidb.stable}/releases/{filename}`,
+ targetPattern: "/{lang}/releases/tidb-self-managed",
+ conditions: { filename: ["_index"] },
+ },
+ {
+ sourcePattern: `/{lang}/tidb-in-kubernetes/main/releases/{filename}`,
+ targetPattern: "/{lang}/releases/tidb-operator",
+ conditions: { filename: ["_index"] },
+ },
+ // tidbcloud with prefix (dedicated, starter, etc.)
+ // When filename = "_index": /en/tidbcloud/tidb-cloud/{prefix}/_index.md -> /en/tidbcloud/{prefix}/
+ // When filename != "_index": /en/tidbcloud/tidb-cloud/{prefix}/{filename}.md -> /en/tidbcloud/{filename}/
+ {
+ sourcePattern:
+ "/{lang}/tidbcloud/{branch}/tidb-cloud/{...prefixes}/{filename}",
+ targetPattern: "/{lang}/tidbcloud/{filename}",
+ filenameTransform: {
+ ignoreIf: ["_index"],
+ conditionalTarget: {
+ keepIf: ["_index"],
+ keepTargetPattern: "/{lang}/tidbcloud/{prefixes}",
+ },
+ },
+ },
+ // develop namespace in tidb folder
+ // When filename = "_index": /en/tidb/master/develop/{folders}/_index.md -> /en/developer/{folders}/
+ // When filename != "_index": /en/tidb/master/develop/{folders}/{filename}.md -> /en/developer/{filename}/
+ {
+ sourcePattern: `/{lang}/tidb/${CONFIG.docs.tidb.stable}/{folder}/{...folders}/{filename}`,
+ targetPattern: "/{lang}/developer/{filename}",
+ conditions: {
+ folder: ["develop"],
+ },
+ filenameTransform: {
+ ignoreIf: ["_index"],
+ conditionalTarget: {
+ keepIf: ["_index"],
+ keepTargetPattern: "/{lang}/developer/{folders}",
+ },
+ },
+ },
+ // best-practices, api, ai namespace in tidb folder
+ // When filename = "_index": /en/tidb/master/best-practices/{folders}/_index.md -> /en/best-practices/{folders}/
+ // When filename != "_index": /en/tidb/master/api/{folders}/{filename}.md -> /en/api/{filename}/
+ {
+ sourcePattern: `/{lang}/tidb/${CONFIG.docs.tidb.stable}/{folder}/{...folders}/{filename}`,
+ targetPattern: "/{lang}/{folder}/{filename}",
+ conditions: {
+ folder: ["best-practices", "api", "ai"],
+ },
+ filenameTransform: {
+ ignoreIf: ["_index"],
+ conditionalTarget: {
+ keepIf: ["_index"],
+ keepTargetPattern: "/{lang}/{folder}/{folders}",
+ },
+ },
+ },
+ // tidb with branch and optional folders
+ // /en/tidb/master/{...folders}/{filename} -> /en/tidb/stable/{filename}
+ // /en/tidb/release-8.5/{...folders}/{filename} -> /en/tidb/v8.5/{filename}
+ {
+ sourcePattern: "/{lang}/tidb/{branch}/{...folders}/{filename}",
+ targetPattern: "/{lang}/tidb/{branch:branch-alias-tidb}/{filename}",
+ filenameTransform: {
+ ignoreIf: ["_index", "_docHome"],
+ },
+ },
+ // tidb-in-kubernetes with branch and optional folders
+ // /en/tidb-in-kubernetes/main/{...folders}/{filename} -> /en/tidb-in-kubernetes/stable/{filename}
+ // /en/tidb-in-kubernetes/release-1.6/{...folders}/{filename} -> /en/tidb-in-kubernetes/v1.6/{filename}
+ {
+ sourcePattern:
+ "/{lang}/tidb-in-kubernetes/{branch}/{...folders}/{filename}",
+ targetPattern:
+ "/{lang}/tidb-in-kubernetes/{branch:branch-alias-tidb-in-kubernetes}/{filename}",
+ filenameTransform: {
+ ignoreIf: ["_index", "_docHome"],
+ },
+ },
+ // Fallback: /{lang}/{repo}/{...any}/{filename} -> /{lang}/{repo}/{filename}
+ {
+ sourcePattern: "/{lang}/{repo}/{...any}/{filename}",
+ targetPattern: "/{lang}/{repo}/{filename}",
+ filenameTransform: {
+ ignoreIf: ["_index", "_docHome"],
+ },
+ },
+ ],
+
+ aliases: {
+ // Branch alias for tidb: used in {branch:branch-alias-tidb}
+ "branch-alias-tidb": {
+ mappings: {
+ master: "dev",
+ // Exact match for tidb stable branch
+ [CONFIG.docs.tidb.stable]: "stable",
+ // Wildcard pattern: release-* -> v*
+ // Matches any branch starting with "release-" and replaces with "v" prefix
+ // Examples:
+ // release-8.5 -> v8.5
+ // release-8.1 -> v8.1
+ // release-7.5 -> v7.5
+ "release-*": "v*",
+ },
+ },
+ // Branch alias for tidb-in-kubernetes: used in {branch:branch-alias-tidb-in-kubernetes}
+ "branch-alias-tidb-in-kubernetes": {
+ mappings: {
+ main: "dev",
+ // Exact match for tidb-in-kubernetes stable branch
+ [CONFIG.docs["tidb-in-kubernetes"].stable]: "stable",
+ // Wildcard pattern: release-* -> v*
+ // Matches any branch starting with "release-" and replaces with "v" prefix
+ // Examples:
+ // release-1.6 -> v1.6
+ // release-1.5 -> v1.5
+ // release-2.0 -> v2.0
+ "release-*": "v*",
+ },
+ },
+ },
+};
diff --git a/gatsby/url-resolver/index.ts b/gatsby/url-resolver/index.ts
new file mode 100644
index 000000000..e46f39df7
--- /dev/null
+++ b/gatsby/url-resolver/index.ts
@@ -0,0 +1,28 @@
+/**
+ * URL Resolver - Main entry point
+ *
+ * This module provides utilities for:
+ * - Mapping source file paths to published URLs
+ */
+
+// Export types
+export type {
+ PathMappingRule,
+ AliasMapping,
+ AliasPattern,
+ UrlResolverConfig,
+ ParsedSourcePath,
+ FileUrlContext,
+} from "./types";
+
+// Export URL resolver functions
+export { parseSourcePath, calculateFileUrl, calculateFileUrlWithConfig, clearUrlResolverCache } from "./url-resolver";
+
+// Export pattern matcher utilities (for advanced use cases)
+export { matchPattern, applyPattern } from "./pattern-matcher";
+
+// Export alias utilities
+export { getAlias, getVariableAlias } from "./branch-alias";
+
+// Export default configuration
+export { defaultUrlResolverConfig } from "./config";
diff --git a/gatsby/url-resolver/pattern-matcher.ts b/gatsby/url-resolver/pattern-matcher.ts
new file mode 100644
index 000000000..c101b38fa
--- /dev/null
+++ b/gatsby/url-resolver/pattern-matcher.ts
@@ -0,0 +1,223 @@
+/**
+ * Pattern matching utilities for URL resolver
+ */
+
+import { getAlias } from "./branch-alias";
+
+// Cache for parsed pattern parts
+const patternPartsCache = new Map();
+
+/**
+ * Match path segments against a pattern
+ * Supports patterns with variable number of segments using {...variableName} syntax
+ * Variables are dynamically extracted from the pattern
+ *
+ * Examples:
+ * - {...folders} matches 0 or more segments, accessible as {folders} in target
+ * - {...prefix} matches 0 or more segments, accessible as {prefix} in target
+ */
+export function matchPattern(
+ pattern: string,
+ segments: string[]
+): Record | null {
+ // Cache pattern parts parsing
+ let patternParts = patternPartsCache.get(pattern);
+ if (!patternParts) {
+ patternParts = pattern
+ .split("/")
+ .filter((p) => p.length > 0)
+ .filter((p) => !p.startsWith("/"));
+ patternPartsCache.set(pattern, patternParts);
+ }
+
+ const result: Record = {};
+ let segmentIndex = 0;
+ let patternIndex = 0;
+
+ while (patternIndex < patternParts.length && segmentIndex < segments.length) {
+ const patternPart = patternParts[patternIndex];
+ const segment = segments[segmentIndex];
+
+ // Handle variable segments pattern {...variableName}
+ // e.g., {...folders} -> variable name is "folders", accessible as {folders} in target
+ if (patternPart.startsWith("{...") && patternPart.endsWith("}")) {
+ // Extract variable name from {...variableName}
+ const variableName = patternPart.slice(4, -1); // Remove "{..." and "}"
+
+ // Find the next pattern part after {...variableName}
+ const nextPatternIndex = patternIndex + 1;
+ if (nextPatternIndex < patternParts.length) {
+ // We need to match the remaining patterns to the remaining segments
+ // The variable segments should be everything between current position and where the next pattern matches
+ const remainingPatterns = patternParts.slice(nextPatternIndex);
+ const remainingSegments = segments.slice(segmentIndex);
+
+ // Calculate how many segments should be consumed by variable segments
+ // The remaining patterns need to match the remaining segments
+ // So variable segments = remainingSegments.length - remainingPatterns.length
+ const variableCount =
+ remainingSegments.length - remainingPatterns.length;
+ if (variableCount >= 0) {
+ // Extract variable segments (can be empty if variableCount is 0)
+ const variableSegments = remainingSegments.slice(0, variableCount);
+ // Store with the variable name (without ...)
+ result[variableName] = variableSegments.join("/");
+ // Continue matching from after variable segments
+ segmentIndex += variableCount;
+ patternIndex++;
+ continue;
+ }
+ } else {
+ // {...variableName} is the last pattern part
+ // All remaining segments are variable segments
+ const variableSegments = segments.slice(segmentIndex);
+ result[variableName] = variableSegments.join("/");
+ segmentIndex = segments.length;
+ patternIndex++;
+ continue;
+ }
+ return null;
+ }
+
+ // Handle regular variable patterns {variable}
+ if (patternPart.startsWith("{") && patternPart.endsWith("}")) {
+ const key = patternPart.slice(1, -1);
+ // Skip colon syntax for now (e.g., {branch:branch-alias} is not used in source pattern)
+ result[key] = segment;
+ segmentIndex++;
+ patternIndex++;
+ } else if (patternPart === segment) {
+ // Literal match
+ segmentIndex++;
+ patternIndex++;
+ } else {
+ // No match
+ return null;
+ }
+ }
+
+ // Handle case where {...variableName} is the last pattern part and there are no more segments
+ // This allows {...variableName} at the end to match 0 segments
+ if (patternIndex < patternParts.length && segmentIndex === segments.length) {
+ const remainingPatternPart = patternParts[patternIndex];
+ if (
+ remainingPatternPart.startsWith("{...") &&
+ remainingPatternPart.endsWith("}")
+ ) {
+ // This is a {...variableName} pattern at the end, allow it to match 0 segments
+ const variableName = remainingPatternPart.slice(4, -1);
+ result[variableName] = "";
+ patternIndex++;
+ }
+ }
+
+ // Check if we consumed all segments and patterns
+ if (
+ segmentIndex !== segments.length ||
+ patternIndex !== patternParts.length
+ ) {
+ return null;
+ }
+
+ return result;
+}
+
+/**
+ * Apply pattern to generate URL from variables
+ * Supports variable references like {folders}, {prefix}, etc.
+ * Empty variables (from {...variableName} matching 0 segments) are skipped
+ * Supports alias syntax: {variable:alias-name} -> uses aliases['alias-name']
+ */
+export function applyPattern(
+ pattern: string,
+ variables: Record,
+ config?: {
+ aliases?: {
+ [aliasName: string]: {
+ context?: Record;
+ mappings: any;
+ };
+ };
+ }
+): string {
+ // Cache pattern parts parsing
+ let parts = patternPartsCache.get(pattern);
+ if (!parts) {
+ parts = pattern
+ .split("/")
+ .filter((p) => p.length > 0)
+ .filter((p) => !p.startsWith("/"));
+ patternPartsCache.set(pattern, parts);
+ }
+
+ const result: string[] = [];
+ for (const part of parts) {
+ if (part.startsWith("{") && part.endsWith("}")) {
+ const key = part.slice(1, -1);
+ // Handle variable:alias-name syntax (e.g., {branch:branch-alias}, {repo:repo-alias})
+ if (key.includes(":")) {
+ const [varKey, aliasName] = key.split(":");
+ const value = variables[varKey];
+
+ if (value && config?.aliases?.[aliasName]) {
+ // Try to get alias from config
+ const aliasConfig = config.aliases[aliasName];
+
+ // Check context conditions if specified
+ let contextMatches = true;
+ if (aliasConfig.context) {
+ for (const [ctxVarName, allowedValues] of Object.entries(
+ aliasConfig.context
+ )) {
+ const ctxValue = variables[ctxVarName];
+ if (ctxValue && allowedValues) {
+ if (!allowedValues.includes(ctxValue)) {
+ contextMatches = false;
+ break;
+ }
+ }
+ }
+ }
+
+ if (contextMatches) {
+ const alias = getAlias(aliasConfig.mappings, value);
+ if (alias) {
+ result.push(alias);
+ } else if (value) {
+ result.push(value);
+ }
+ } else if (value) {
+ result.push(value);
+ }
+ } else if (value) {
+ // Fallback to original value if alias not found
+ result.push(value);
+ }
+ } else {
+ const value = variables[key];
+ // Only push if value exists and is not empty
+ // Empty string means {...variableName} matched 0 segments, so skip it
+ if (value && value.length > 0) {
+ // If value contains "/", split and push each segment
+ // This handles cases like folders: "folder1/folder2" -> ["folder1", "folder2"]
+ if (value.includes("/")) {
+ result.push(...value.split("/"));
+ } else {
+ result.push(value);
+ }
+ }
+ }
+ } else {
+ result.push(part);
+ }
+ }
+
+ return "/" + result.join("/");
+}
+
+/**
+ * Clear pattern parts cache (useful for testing or when patterns change)
+ */
+export function clearPatternCache(): void {
+ patternPartsCache.clear();
+}
diff --git a/gatsby/url-resolver/types.ts b/gatsby/url-resolver/types.ts
new file mode 100644
index 000000000..aeba088ed
--- /dev/null
+++ b/gatsby/url-resolver/types.ts
@@ -0,0 +1,86 @@
+/**
+ * Type definitions for URL resolver
+ */
+
+export interface PathMappingRule {
+ // Pattern to match source path segments
+ // e.g., "/{lang}/{repo}/{namespace}/{prefix}/{filename}"
+ sourcePattern: string;
+ // Target URL pattern
+ // e.g., "/{lang}/{repo}/{prefix}/{filename}"
+ targetPattern: string;
+ // Conditions for this rule to apply
+ // Supports arbitrary variables from sourcePattern
+ // e.g., { repo: ["tidbcloud"], folder: ["develop", "api"] }
+ conditions?: Record;
+ // Special handling for filename
+ filenameTransform?: {
+ ignoreIf?: string[]; // e.g., ["_index"] - ignore filename if it matches
+ // Conditional target pattern based on filename
+ // If filename matches any value in keepIf, use keepTargetPattern, otherwise use targetPattern
+ conditionalTarget?: {
+ keepIf?: string[]; // e.g., ["_index"] - use keepTargetPattern if filename matches
+ keepTargetPattern: string; // Alternative target pattern when filename matches keepIf
+ };
+ };
+}
+
+export interface AliasPattern {
+ // Pattern to match value (supports wildcard * and regex)
+ // e.g., "release-*" or "release-(.*)"
+ pattern: string;
+ // Replacement pattern (supports $1, $2, etc. for captured groups)
+ // e.g., "v$1" for "release-8.5" -> "v8.5"
+ replacement: string;
+ // Whether to use regex matching (default: false, uses wildcard matching)
+ useRegex?: boolean;
+}
+
+export interface AliasMapping {
+ // Value to alias mapping
+ // Can be:
+ // 1. Simple string mapping: { "master": "stable" }
+ // 2. Pattern-based mapping: { "release-*": "v*" } (wildcard)
+ // 3. Regex-based mapping: { pattern: "release-(.*)", replacement: "v$1", useRegex: true }
+ [value: string]: string | AliasPattern;
+}
+
+export interface UrlResolverConfig {
+ // Base path for source files
+ sourceBasePath: string;
+ // Path mapping rules (ordered, first match wins)
+ pathMappings: PathMappingRule[];
+ // Alias mappings for variables
+ // Supports arbitrary alias names like 'branch-alias', 'repo-alias', etc.
+ // Usage in targetPattern: {branch:branch-alias} -> uses aliases['branch-alias']
+ aliases?: {
+ [aliasName: string]: {
+ // Optional context conditions for the alias
+ // e.g., { repo: ["tidb", "tidb-in-kubernetes"] } - only apply when repo matches
+ context?: Record;
+ // The actual alias mappings
+ mappings: AliasMapping;
+ };
+ };
+ // Default language to omit from URL (e.g., "en" -> /tidb/stable instead of /en/tidb/stable)
+ defaultLanguage?: string;
+ // Control trailing slash behavior
+ // "always" - always add trailing slash
+ // "never" - never add trailing slash
+ // "auto" - add for non-index files, remove for index files (default)
+ trailingSlash?: "always" | "never" | "auto";
+}
+
+export interface ParsedSourcePath {
+ segments: string[];
+ filename: string;
+}
+
+export interface FileUrlContext {
+ lang: string;
+ repo: string;
+ branch?: string;
+ version?: string;
+ prefix?: string;
+ filename?: string;
+}
diff --git a/gatsby/url-resolver/url-resolver.ts b/gatsby/url-resolver/url-resolver.ts
new file mode 100644
index 000000000..be6bfcdb0
--- /dev/null
+++ b/gatsby/url-resolver/url-resolver.ts
@@ -0,0 +1,319 @@
+/**
+ * URL resolver for mapping source file paths to published URLs
+ */
+
+import type {
+ PathMappingRule,
+ UrlResolverConfig,
+ ParsedSourcePath,
+} from "./types";
+import {
+ matchPattern,
+ applyPattern,
+ clearPatternCache,
+} from "./pattern-matcher";
+import { defaultUrlResolverConfig } from "./config";
+
+// Cache for calculateFileUrl results
+// Key: absolutePath + omitDefaultLanguage flag
+// Value: resolved URL or null
+const fileUrlCache = new Map();
+
+// Cache for parseSourcePath results
+// Key: absolutePath + sourceBasePath
+// Value: ParsedSourcePath or null
+const parsedPathCache = new Map();
+
+/**
+ * Parse source file path into segments and filename
+ * No hardcoded logic - variables will be extracted via pattern matching
+ *
+ * Supports both absolute paths and relative paths (slug format):
+ * - Absolute path: "/path/to/docs/markdown-pages/en/tidb/master/alert-rules.md"
+ * - Relative path (slug): "en/tidb/master/alert-rules" (will be treated as relative to sourceBasePath)
+ *
+ * A path is considered a slug (relative path) if:
+ * - It doesn't start with sourceBasePath
+ * - It doesn't start with "/" (unless it's a valid slug starting with lang code)
+ * - It looks like a slug format (starts with lang code like "en/", "zh/", "ja/")
+ */
+export function parseSourcePath(
+ absolutePath: string,
+ sourceBasePath: string
+): ParsedSourcePath | null {
+ // Check cache first
+ const cacheKey = `${absolutePath}::${sourceBasePath}`;
+ const cached = parsedPathCache.get(cacheKey);
+ if (cached !== undefined) {
+ return cached;
+ }
+
+ // Normalize paths
+ const normalizedBase = sourceBasePath.replace(/\/$/, "");
+ const normalizedPath = absolutePath.replace(/\/$/, "");
+
+ let relativePath: string;
+
+ // Check if path is absolute (starts with sourceBasePath)
+ if (normalizedPath.startsWith(normalizedBase)) {
+ // Absolute path: extract relative path
+ relativePath = normalizedPath.slice(normalizedBase.length);
+ } else {
+ // Check if it looks like a slug (relative path)
+ // Remove leading slash if present for checking
+ const pathWithoutLeadingSlash = normalizedPath.startsWith("/")
+ ? normalizedPath.slice(1)
+ : normalizedPath;
+
+ // Slug format: must start with valid lang code (en/, zh/, ja/)
+ // This ensures we only accept valid slug formats, not arbitrary paths
+ const isSlugFormat = /^(en|zh|ja)\//.test(pathWithoutLeadingSlash);
+
+ if (isSlugFormat) {
+ // Relative path (slug format): use path without leading slash
+ relativePath = pathWithoutLeadingSlash;
+ } else {
+ // Invalid path: doesn't match absolute path and doesn't look like a slug
+ return null;
+ }
+ }
+
+ // Remove leading slash for processing
+ if (relativePath.startsWith("/")) {
+ relativePath = relativePath.slice(1);
+ }
+
+ const segments = relativePath
+ .split("/")
+ .filter((s) => s.length > 0)
+ .filter((s) => !s.startsWith("."));
+
+ if (segments.length < 2) {
+ // At least: lang, filename (or more)
+ return null;
+ }
+
+ // Extract filename (last segment)
+ // If it doesn't have .md extension, add it for consistency
+ let lastSegment = segments[segments.length - 1];
+ if (!lastSegment.endsWith(".md")) {
+ lastSegment = lastSegment + ".md";
+ }
+ const filename = lastSegment.replace(/\.md$/, "");
+
+ // Update segments array to include .md extension if it was added
+ segments[segments.length - 1] = lastSegment;
+
+ const result: ParsedSourcePath = {
+ segments,
+ filename,
+ };
+
+ // Cache the result
+ parsedPathCache.set(cacheKey, result);
+ return result;
+}
+
+/**
+ * Check if conditions are met
+ * Conditions are checked against matched variables from pattern
+ * Supports arbitrary variables from sourcePattern
+ */
+function checkConditions(
+ conditions: PathMappingRule["conditions"],
+ variables: Record
+): boolean {
+ if (!conditions) return true;
+
+ // Check each condition - supports arbitrary variable names
+ for (const [variableName, allowedValues] of Object.entries(conditions)) {
+ const variableValue = variables[variableName];
+ if (variableValue && allowedValues) {
+ if (!allowedValues.includes(variableValue)) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+}
+
+/**
+ * Calculate file URL from source path (internal implementation with config)
+ * Variables are dynamically extracted via pattern matching
+ */
+export function calculateFileUrlWithConfig(
+ absolutePath: string,
+ config: UrlResolverConfig,
+ omitDefaultLanguage: boolean = false
+): string | null {
+ // Check cache first
+ const cacheKey = `${absolutePath}::${omitDefaultLanguage}`;
+ const cached = fileUrlCache.get(cacheKey);
+ if (cached !== undefined) {
+ return cached;
+ }
+
+ const parsed = parseSourcePath(absolutePath, config.sourceBasePath);
+ if (!parsed) {
+ // Cache null result
+ fileUrlCache.set(cacheKey, null);
+ return null;
+ }
+
+ // Build segments for pattern matching (include filename)
+ const allSegments = [...parsed.segments];
+
+ // Try each mapping rule in order
+ for (const rule of config.pathMappings) {
+ // Try to match source pattern first to extract variables
+ const variables = matchPattern(rule.sourcePattern, allSegments);
+ if (!variables) {
+ continue;
+ }
+
+ // Replace filename variable with parsed filename (without .md extension)
+ // This ensures conditions can check against the actual filename without extension
+ if (variables.filename) {
+ variables.filename = parsed.filename;
+ }
+
+ // Check conditions using matched variables
+ if (!checkConditions(rule.conditions, variables)) {
+ continue;
+ }
+
+ // Handle filename transform
+ let finalFilename = parsed.filename;
+ if (rule.filenameTransform?.ignoreIf) {
+ if (rule.filenameTransform.ignoreIf.includes(parsed.filename)) {
+ finalFilename = "";
+ }
+ }
+
+ // Determine which target pattern to use
+ let targetPatternToUse = rule.targetPattern;
+ if (rule.filenameTransform?.conditionalTarget?.keepIf) {
+ if (
+ rule.filenameTransform.conditionalTarget.keepIf.includes(
+ parsed.filename
+ )
+ ) {
+ targetPatternToUse =
+ rule.filenameTransform.conditionalTarget.keepTargetPattern;
+ }
+ }
+
+ // Build target URL
+ const targetVars = { ...variables };
+ if (finalFilename) {
+ targetVars.filename = finalFilename;
+ } else {
+ delete targetVars.filename;
+ }
+
+ let url = applyPattern(targetPatternToUse, targetVars, config);
+
+ // Handle default language omission
+ // Only omit if omitDefaultLanguage is explicitly true
+ if (
+ omitDefaultLanguage === true &&
+ config.defaultLanguage &&
+ url.startsWith(`/${config.defaultLanguage}/`)
+ ) {
+ url = url.replace(`/${config.defaultLanguage}/`, "/");
+ }
+
+ // Handle trailing slash based on config
+ const trailingSlash = config.trailingSlash || "auto";
+ if (trailingSlash === "never") {
+ url = url.replace(/\/$/, "");
+ } else if (trailingSlash === "always") {
+ if (!url.endsWith("/")) {
+ url = url + "/";
+ }
+ } else {
+ // "auto" mode: remove trailing slash if filename was ignored, add for non-index files
+ if (!finalFilename && url.endsWith("/")) {
+ url = url.slice(0, -1);
+ } else if (finalFilename && !url.endsWith("/")) {
+ url = url + "/";
+ }
+ }
+
+ return url;
+ }
+
+ // Fallback: use default rule
+ // Extract at least lang and repo from segments
+ if (parsed.segments.length >= 2) {
+ const lang = parsed.segments[0];
+ const repo = parsed.segments[1];
+ let url = `/${lang}/${repo}`;
+ if (parsed.filename && parsed.filename !== "_index") {
+ url = `${url}/${parsed.filename}/`;
+ } else {
+ url = url + "/";
+ }
+
+ // Handle default language omission
+ // Only omit if omitDefaultLanguage is explicitly true
+ if (
+ omitDefaultLanguage === true &&
+ config.defaultLanguage &&
+ url.startsWith(`/${config.defaultLanguage}/`)
+ ) {
+ url = url.replace(`/${config.defaultLanguage}/`, "/");
+ }
+
+ // Handle trailing slash based on config
+ const trailingSlash = config.trailingSlash || "auto";
+ if (trailingSlash === "never") {
+ url = url.replace(/\/$/, "");
+ } else if (trailingSlash === "always") {
+ if (!url.endsWith("/")) {
+ url = url + "/";
+ }
+ }
+ // "auto" mode is already handled above
+
+ // Cache the result before returning
+ fileUrlCache.set(cacheKey, url);
+ return url;
+ }
+
+ // Cache null result
+ fileUrlCache.set(cacheKey, null);
+ // Cache null result
+ fileUrlCache.set(cacheKey, null);
+ return null;
+}
+
+/**
+ * Calculate file URL from source path
+ * Variables are dynamically extracted via pattern matching
+ * Uses global defaultUrlResolverConfig
+ *
+ * @param absolutePath - Absolute path to the source file or slug format (e.g., "en/tidb/master/alert-rules")
+ * @param omitDefaultLanguage - Whether to omit default language prefix (default: false, keeps language prefix)
+ */
+export function calculateFileUrl(
+ absolutePath: string,
+ omitDefaultLanguage: boolean = false
+): string | null {
+ return calculateFileUrlWithConfig(
+ absolutePath,
+ defaultUrlResolverConfig,
+ omitDefaultLanguage
+ );
+}
+
+/**
+ * Clear all caches (useful for testing or when config changes)
+ */
+export function clearUrlResolverCache(): void {
+ fileUrlCache.clear();
+ parsedPathCache.clear();
+ // Also clear pattern cache
+ clearPatternCache();
+}
diff --git a/jest.config.js b/jest.config.js
new file mode 100644
index 000000000..9218ec604
--- /dev/null
+++ b/jest.config.js
@@ -0,0 +1,17 @@
+/**
+ * Jest configuration for TypeScript tests
+ */
+
+module.exports = {
+ roots: ["/gatsby"],
+ testMatch: ["**/__tests__/**/*.test.{ts,tsx,js,jsx}"],
+ transform: {
+ "^.+\\.tsx?$": "ts-jest",
+ },
+ moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
+ collectCoverageFrom: [
+ "gatsby/**/*.{ts,tsx}",
+ "!gatsby/**/*.d.ts",
+ "!gatsby/**/__tests__/**",
+ ],
+};
diff --git a/package.json b/package.json
index 9481a66ff..e20facaaa 100644
--- a/package.json
+++ b/package.json
@@ -78,8 +78,9 @@
"@ti-fe/cli": "^0.12.0",
"@ti-fe/prettier-config": "^1.0.3",
"@types/fs-extra": "^11.0.4",
+ "@types/jest": "^30.0.0",
"@types/mdx-js__react": "^1.5.5",
- "@types/node": "^17.0.21",
+ "@types/node": "^25.0.7",
"@types/react-dom": "^18.0.5",
"@types/signale": "^1.4.3",
"anafanafo": "^1.0.0",
@@ -89,11 +90,13 @@
"gatsby-plugin-root-import": "^2.0.8",
"husky": "^7.0.4",
"is-ci": "^3.0.1",
+ "jest": "^30.2.0",
"lint-staged": "^12.1.2",
"patch-package": "^8.0.0",
"pegjs": "^0.10.0",
"prettier": "2.5.1",
"sass": "^1.45.0",
+ "ts-jest": "^29.4.6",
"ts-node": "^10.4.0",
"typescript": "^4.5.4"
},
@@ -103,12 +106,13 @@
"license": "MIT",
"scripts": {
"postinstall": "patch-package",
+ "dev": "yarn start",
"start": "gatsby develop",
"start:0.0.0.0": "gatsby develop -H 0.0.0.0",
"build": "gatsby build",
"serve": "gatsby serve",
"clean": "gatsby clean",
- "test": "jest --coverage --roots src",
+ "test": "jest --coverage --roots gatsby",
"prepare": "is-ci || husky install"
},
"lint-staged": {
diff --git a/src/components/Card/FeedbackSection/FeedbackSection.tsx b/src/components/Card/FeedbackSection/FeedbackSection.tsx
index 979c5c76b..6e76e11f6 100644
--- a/src/components/Card/FeedbackSection/FeedbackSection.tsx
+++ b/src/components/Card/FeedbackSection/FeedbackSection.tsx
@@ -8,6 +8,7 @@ import {
Radio,
FormControlLabel,
TextField,
+ Button,
} from "@mui/material";
import { ThumbUpOutlined, ThumbDownOutlined } from "@mui/icons-material";
import { Locale } from "shared/interface";
@@ -15,12 +16,7 @@ import { useState } from "react";
import { trackCustomEvent } from "gatsby-plugin-google-analytics";
import { submitFeedbackDetail, submitLiteFeedback } from "./tracking";
import { FeedbackCategory } from "./types";
-import {
- ActionButton,
- controlLabelSx,
- labelProps,
- radioSx,
-} from "./components";
+import { controlLabelSx, labelProps, radioSx } from "./components";
interface FeedbackSectionProps {
title: string;
@@ -120,8 +116,9 @@ export function FeedbackSection({ title, locale }: FeedbackSectionProps) {
{thumbVisible && (
- }
className="FeedbackBtn-thumbUp"
@@ -129,9 +126,10 @@ export function FeedbackSection({ title, locale }: FeedbackSectionProps) {
onClick={() => onThumbClick(true)}
>
-
-
+ }
className="FeedbackBtn-thumbDown"
@@ -139,7 +137,7 @@ export function FeedbackSection({ title, locale }: FeedbackSectionProps) {
onClick={() => onThumbClick(false)}
>
-
+
)}
{surveyVisible && helpful && (
@@ -204,17 +202,23 @@ export function FeedbackSection({ title, locale }: FeedbackSectionProps) {
-
-
-
+
+
+
)}
@@ -289,17 +293,23 @@ export function FeedbackSection({ title, locale }: FeedbackSectionProps) {
-
-
-
+
+
+
)}
diff --git a/src/components/Card/FeedbackSection/components.ts b/src/components/Card/FeedbackSection/components.ts
index a4972391f..2035e2fcf 100644
--- a/src/components/Card/FeedbackSection/components.ts
+++ b/src/components/Card/FeedbackSection/components.ts
@@ -1,18 +1,5 @@
import { Button, styled } from "@mui/material";
-export const ActionButton = styled(Button)(({ theme }) => ({
- backgroundColor: "#F9F9F9",
- borderColor: "#D9D9D9",
- color: theme.palette.text.primary,
- "&:hover": {
- backgroundColor: "#F9F9F9",
- borderColor: theme.palette.text.primary,
- },
- ".MuiButton-startIcon": {
- marginRight: 4,
- },
-}));
-
export const controlLabelSx = {
ml: 0,
py: "6px",
diff --git a/src/components/Layout/Banner/Banner.tsx b/src/components/Layout/Banner/Banner.tsx
index 75024decb..8ab44b4d5 100644
--- a/src/components/Layout/Banner/Banner.tsx
+++ b/src/components/Layout/Banner/Banner.tsx
@@ -1,4 +1,6 @@
-import { Box, Divider, Stack, Typography } from "@mui/material";
+import * as React from "react";
+import { Box, Stack, Typography } from "@mui/material";
+import { HEADER_HEIGHT } from "shared/headerHeight";
export function Banner({
url,
@@ -17,14 +19,12 @@ export function Banner({
typeof text === "string" ? (
) : (
- text
+ {text}
)
)}
diff --git a/src/components/Layout/Header.tsx b/src/components/Layout/Header.tsx
deleted file mode 100644
index 106a4fa93..000000000
--- a/src/components/Layout/Header.tsx
+++ /dev/null
@@ -1,177 +0,0 @@
-import * as React from "react";
-import AppBar from "@mui/material/AppBar";
-import Box from "@mui/material/Box";
-import Toolbar from "@mui/material/Toolbar";
-import { useTheme } from "@mui/material/styles";
-
-import LinkComponent, { BlueAnchorLink } from "components/Link";
-import HeaderNavStack, {
- HeaderNavStackMobile,
-} from "components/Layout/HeaderNav";
-import HeaderAction from "components/Layout/HeaderAction";
-
-import TiDBLogo from "media/logo/tidb-logo-withtext.svg";
-
-import { Locale, BuildType, PathConfig } from "shared/interface";
-import { GTMEvent, gtmTrack } from "shared/utils/gtm";
-import { Banner } from "./Banner";
-import { generateDocsHomeUrl, generateUrl } from "shared/utils";
-import { useI18next } from "gatsby-plugin-react-i18next";
-import { useIsAutoTranslation } from "shared/useIsAutoTranslation";
-import { ErrorOutlineOutlined } from "@mui/icons-material";
-
-interface HeaderProps {
- bannerEnabled?: boolean;
- menu?: React.ReactNode;
- locales: Locale[];
- docInfo?: { type: string; version: string };
- buildType?: BuildType;
- pageUrl?: string;
- name?: string;
- pathConfig?: PathConfig;
-}
-
-export default function Header(props: HeaderProps) {
- const { language } = useI18next();
- const theme = useTheme();
-
- return (
-
-
-
-
- {props.menu}
-
-
- gtmTrack(GTMEvent.ClickHeadNav, {
- item_name: "logo",
- })
- }
- >
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-const HeaderBanner = (props: HeaderProps) => {
- const { t } = useI18next();
- const isAutoTranslation = useIsAutoTranslation(props.pageUrl || "");
- const urlAutoTranslation =
- props.pathConfig?.repo === "tidbcloud"
- ? `/tidbcloud/${props.name === "_index" ? "" : props.name}`
- : `/${props.pathConfig?.repo}/${props.pathConfig?.version || "stable"}/${
- props.name === "_index" ? "" : props.name
- }`;
-
- let archivedTargetUrl = "";
- if (props.name && props.pathConfig) {
- const stableCfg = { ...props.pathConfig, version: "stable" };
- const path = generateUrl(props.name, stableCfg);
- archivedTargetUrl = `https://docs.pingcap.com${path}`;
- } else {
- const lang =
- props.pathConfig?.locale === Locale.en
- ? ""
- : `/${props.pathConfig?.locale}`;
- archivedTargetUrl = `https://docs.pingcap.com${lang}/tidb/stable/`;
- }
-
- if (props.buildType === "archive") {
- return (
-
- }
- textList={[
- t("banner.archive.title"),
-
- {t("banner.archive.viewLatestLTSVersion")} ↗
- ,
- ]}
- />
- );
- }
-
- if (isAutoTranslation) {
- return (
-
- // }
- logo={"📣"}
- textList={[
-
- {t("banner.autoTrans.title1")}
- ,
- t("banner.autoTrans.title2"),
-
- {t("banner.autoTrans.end")}
- ,
- ]}
- />
- );
- }
-
- return props.bannerEnabled ? (
-
- {t("banner.campaign.title1")}
- ,
- t("banner.campaign.title2"),
-
- {t("banner.campaign.end")}
- ,
- ]}
- />
- ) : null;
-};
diff --git a/src/components/Layout/Header/HeaderAction.tsx b/src/components/Layout/Header/HeaderAction.tsx
new file mode 100644
index 000000000..a82241efa
--- /dev/null
+++ b/src/components/Layout/Header/HeaderAction.tsx
@@ -0,0 +1,210 @@
+import * as React from "react";
+import { useI18next } from "gatsby-plugin-react-i18next";
+
+import Stack from "@mui/material/Stack";
+import Button from "@mui/material/Button";
+import Menu from "@mui/material/Menu";
+import MenuItem from "@mui/material/MenuItem";
+import IconButton from "@mui/material/IconButton";
+import Typography from "@mui/material/Typography";
+import StarIcon from "media/icons/star.svg";
+
+import CloudIcon from "@mui/icons-material/Cloud";
+import { useTheme } from "@mui/material/styles";
+
+import Search from "components/Search";
+
+import { Locale, BuildType, TOCNamespace } from "shared/interface";
+import { Link } from "gatsby";
+import { useIsAutoTranslation } from "shared/useIsAutoTranslation";
+
+const useTiDBAIStatus = () => {
+ const [showTiDBAIButton, setShowTiDBAIButton] = React.useState(true);
+ const [initializingTiDBAI, setInitializingTiDBAI] = React.useState(true);
+
+ React.useEffect(() => {
+ if (!!window.tidbai) {
+ setInitializingTiDBAI(false);
+ }
+
+ const onTiDBAIInitialized = () => {
+ setInitializingTiDBAI(false);
+ };
+ const onTiDBAIError = () => {
+ setInitializingTiDBAI(false);
+ setShowTiDBAIButton(false);
+ };
+ window.addEventListener("tidbaiinitialized", onTiDBAIInitialized);
+ window.addEventListener("tidbaierror", onTiDBAIError);
+
+ const timer = setTimeout(() => {
+ if (!window.tidbai) {
+ setInitializingTiDBAI(false);
+ setShowTiDBAIButton(false);
+ }
+ }, 10000);
+ return () => {
+ clearTimeout(timer);
+ window.removeEventListener("tidbaiinitialized", onTiDBAIInitialized);
+ window.removeEventListener("tidbaierror", onTiDBAIError);
+ };
+ }, []);
+
+ return { showTiDBAIButton, initializingTiDBAI };
+};
+
+export default function HeaderAction(props: {
+ supportedLocales: Locale[];
+ docInfo?: { type: string; version: string };
+ buildType?: BuildType;
+ namespace: TOCNamespace;
+}) {
+ const { docInfo, buildType, namespace } = props;
+ const { language, t } = useI18next();
+ const { showTiDBAIButton, initializingTiDBAI } = useTiDBAIStatus();
+ const isAutoTranslation = useIsAutoTranslation(namespace);
+
+ return (
+
+ {docInfo && !isAutoTranslation && buildType !== "archive" && (
+ <>
+
+ {language === "en" && showTiDBAIButton && (
+ }
+ disabled={initializingTiDBAI}
+ size="medium"
+ sx={{
+ fontSize: "14px",
+ display: {
+ xs: "none",
+ xl: "flex",
+ },
+ }}
+ onClick={() => {
+ window.tidbai.open = true;
+ }}
+ >
+ Ask TiDB.ai
+
+ )}
+ >
+ )}
+ {language === "en" && }
+
+ );
+}
+
+const TiDBCloudBtnGroup = () => {
+ const theme = useTheme();
+ const [anchorEl, setAnchorEl] = React.useState(null);
+ const open = Boolean(anchorEl);
+ const handleClick = (event: React.MouseEvent) => {
+ setAnchorEl(event.currentTarget);
+ };
+ const handleClose = () => {
+ setAnchorEl(null);
+ };
+
+ return (
+ <>
+
+
+
+
+
+ {/* Mobile menu */}
+
+
+ >
+ );
+};
diff --git a/src/components/Layout/Header/HeaderNav.tsx b/src/components/Layout/Header/HeaderNav.tsx
new file mode 100644
index 000000000..0f34b9dd2
--- /dev/null
+++ b/src/components/Layout/Header/HeaderNav.tsx
@@ -0,0 +1,618 @@
+import * as React from "react";
+import { useI18next } from "gatsby-plugin-react-i18next";
+import Box from "@mui/material/Box";
+import Typography from "@mui/material/Typography";
+import Stack from "@mui/material/Stack";
+import { useTheme } from "@mui/material/styles";
+import MenuItem from "@mui/material/MenuItem";
+import Popover from "@mui/material/Popover";
+import Divider from "@mui/material/Divider";
+
+import LinkComponent from "components/Link";
+import { BuildType, TOCNamespace } from "shared/interface";
+import { GTMEvent, gtmTrack } from "shared/utils/gtm";
+import { useCloudPlan } from "shared/useCloudPlan";
+import ChevronDownIcon from "media/icons/chevron-down.svg";
+import {
+ NavConfig,
+ NavGroupConfig,
+ NavItemConfig,
+} from "./HeaderNavConfigType";
+import { generateNavConfig } from "./HeaderNavConfigData";
+import { clearAllNavStates } from "../LeftNav/LeftNavTree";
+import { getSelectedNavItem } from "./getSelectedNavItem";
+
+export default function HeaderNavStack(props: {
+ buildType?: BuildType;
+ config?: NavConfig[];
+ namespace?: TOCNamespace;
+ onSelectedNavItemChange?: (item: NavItemConfig | null) => void;
+}) {
+ const { language, t } = useI18next();
+ const { cloudPlan } = useCloudPlan();
+
+ // Default configuration (backward compatible)
+ const defaultConfig: NavConfig[] = React.useMemo(() => {
+ if (props.config) {
+ return props.config;
+ }
+ // Use new config generator
+ return generateNavConfig(t, cloudPlan, props.buildType, language);
+ }, [props.config, props.buildType, cloudPlan, t, language]);
+
+ // Find and notify selected item
+ React.useEffect(() => {
+ if (props.onSelectedNavItemChange) {
+ const selectedNavItem = getSelectedNavItem(defaultConfig, props.namespace);
+ props.onSelectedNavItemChange(selectedNavItem);
+ }
+ }, [defaultConfig, props.namespace, props.onSelectedNavItemChange]);
+
+ return (
+
+ {defaultConfig.map((navConfig, index) => {
+ // Check condition
+ if (
+ navConfig.condition &&
+ !navConfig.condition(language, props.buildType)
+ ) {
+ return null;
+ }
+
+ return (
+
+ );
+ })}
+
+ );
+}
+
+const NavGroup = (props: {
+ config: NavConfig;
+ namespace?: TOCNamespace;
+ language?: string;
+}) => {
+ const { config } = props;
+ const theme = useTheme();
+ const [anchorEl, setAnchorEl] = React.useState(null);
+ const open = Boolean(anchorEl);
+ const closeTimeoutRef = React.useRef(null);
+
+ // Check if this is an item or a group without children
+ const isItem = config.type === "item";
+ const isGroupWithoutChildren =
+ config.type === "group" &&
+ (!config.children || config.children.length === 0);
+ const shouldShowPopover = !isItem && !isGroupWithoutChildren;
+
+ const handlePopoverOpen = (event: React.MouseEvent) => {
+ // Clear any pending close timeout
+ if (closeTimeoutRef.current) {
+ clearTimeout(closeTimeoutRef.current);
+ closeTimeoutRef.current = null;
+ }
+ setAnchorEl(event.currentTarget);
+ };
+
+ const handlePopoverClose = () => {
+ // Add a small delay before closing to allow moving to the popover
+ closeTimeoutRef.current = setTimeout(() => {
+ setAnchorEl(null);
+ }, 100);
+ };
+
+ const handlePopoverKeepOpen = () => {
+ // Clear any pending close timeout when mouse enters popover
+ if (closeTimeoutRef.current) {
+ clearTimeout(closeTimeoutRef.current);
+ closeTimeoutRef.current = null;
+ }
+ };
+
+ const handlePopoverToggle = (event: React.MouseEvent) => {
+ // Toggle popover on click
+ if (anchorEl) {
+ // If already open, close it immediately
+ if (closeTimeoutRef.current) {
+ clearTimeout(closeTimeoutRef.current);
+ closeTimeoutRef.current = null;
+ }
+ setAnchorEl(null);
+ } else {
+ // If closed, open it
+ handlePopoverOpen(event);
+ }
+ };
+
+ const handlePopoverCloseImmediate = () => {
+ // Close popover immediately (for click outside)
+ if (closeTimeoutRef.current) {
+ clearTimeout(closeTimeoutRef.current);
+ closeTimeoutRef.current = null;
+ }
+ setAnchorEl(null);
+ };
+
+ React.useEffect(() => {
+ return () => {
+ // Cleanup timeout on unmount
+ if (closeTimeoutRef.current) {
+ clearTimeout(closeTimeoutRef.current);
+ }
+ };
+ }, []);
+
+ // Check if this item/group is selected
+ const isSelected: boolean =
+ isItem && typeof config.selected === "function"
+ ? config.selected(props.namespace)
+ : isItem
+ ? ((config.selected ?? false) as boolean)
+ : false;
+
+ // Check if any child is selected (recursively check nested groups)
+ const hasSelectedChild =
+ !isItem && config.type === "group" && config.children
+ ? config.children.some((child) => {
+ if (child.type === "item") {
+ const childSelected =
+ typeof child.selected === "function"
+ ? child.selected(props.namespace)
+ : child.selected ?? false;
+ return childSelected;
+ } else {
+ // For nested groups, check if any nested child is selected
+ return child.children.some((nestedChild) => {
+ if (nestedChild.type === "item") {
+ const nestedSelected =
+ typeof nestedChild.selected === "function"
+ ? nestedChild.selected(props.namespace)
+ : nestedChild.selected ?? false;
+ return nestedSelected;
+ }
+ return false;
+ });
+ }
+ })
+ : false;
+
+ return (
+ <>
+
+
+ {shouldShowPopover && (
+
+ {(() => {
+ if (config.type !== "group" || !config.children) {
+ return null;
+ }
+ const groups = config.children.filter(
+ (child) => child.type === "group"
+ );
+ const items = config.children.filter(
+ (child) => child.type === "item"
+ );
+
+ return (
+ <>
+ {groups.length > 0 && (
+
+ {groups.map((child, index) => (
+
+
+
+ {child.children.map((nestedChild, nestedIndex) => {
+ if (nestedChild.type === "item") {
+ return (
+
+ );
+ }
+ return null;
+ })}
+
+ {index < groups.length - 1 && (
+
+ )}
+
+ ))}
+
+ )}
+ {items.length > 0 && (
+
+ {items.map((child, index) => (
+
+ ))}
+
+ )}
+ >
+ );
+ })()}
+
+ )}
+ >
+ );
+};
+
+// Group title component
+const GroupTitle = (props: {
+ title: string | React.ReactNode;
+ titleIcon?: React.ReactNode;
+}) => {
+ const theme = useTheme();
+ if (!props.title) return null;
+ return (
+
+ {props.titleIcon && (
+
+ {props.titleIcon}
+
+ )}
+ {props.title}
+
+ );
+};
+
+// Menu item component
+const NavMenuItem = (props: {
+ item: NavItemConfig;
+ groupTitle?: string | React.ReactNode;
+ namespace?: TOCNamespace;
+ onClose: () => void;
+ language?: string;
+}) => {
+ const { item, groupTitle, namespace, onClose, language } = props;
+ const isSelected =
+ typeof item.selected === "function"
+ ? item.selected(namespace)
+ : item.selected ?? false;
+
+ const isDisabled =
+ typeof item.disabled === "function"
+ ? item.disabled(language || "")
+ : item.disabled ?? false;
+
+ const menuItemContent = (
+
+ );
+
+ if (isDisabled) {
+ return menuItemContent;
+ }
+
+ return (
+ {
+ gtmTrack(GTMEvent.ClickHeadNav, {
+ item_name: item.label || item.alt,
+ });
+ }}
+ >
+ {menuItemContent}
+
+ );
+};
+
+// Nav button component (for both item and group)
+const NavButton = (props: {
+ config: NavConfig;
+ isItem: boolean;
+ selected: boolean;
+ hasSelectedChild: boolean;
+ shouldShowPopover: boolean;
+ open: boolean;
+ onMouseEnter?: (event: React.MouseEvent) => void;
+ onMouseLeave?: () => void;
+ onClick?: (event: React.MouseEvent) => void;
+ language?: string;
+}) => {
+ const {
+ config,
+ isItem,
+ selected,
+ hasSelectedChild,
+ shouldShowPopover,
+ open,
+ onMouseEnter,
+ onMouseLeave,
+ onClick,
+ language,
+ } = props;
+ const theme = useTheme();
+ const label = isItem
+ ? (config as NavItemConfig).label
+ : (config as NavGroupConfig).title;
+ const to = isItem ? (config as NavItemConfig).to : undefined;
+ const startIcon = isItem
+ ? (config as NavItemConfig).startIcon
+ : (config as NavGroupConfig).titleIcon;
+ const endIcon = isItem ? (config as NavItemConfig).endIcon : undefined;
+ const alt = isItem ? (config as NavItemConfig).alt : undefined;
+ const isI18n = isItem ? (config as NavItemConfig).isI18n ?? true : true;
+ const disabled = isItem
+ ? (() => {
+ const itemDisabled = (config as NavItemConfig).disabled;
+ if (typeof itemDisabled === "function") {
+ return itemDisabled(language || "");
+ }
+ return itemDisabled ?? false;
+ })()
+ : false;
+
+ // Determine selected state for border styling
+ const isSelectedState = isItem ? selected : hasSelectedChild;
+
+ return (
+ <>
+ {isItem && to ? (
+ // Render as link for item (or disabled text if disabled)
+ disabled ? (
+
+ {startIcon}
+ {label}
+ {endIcon}
+
+ ) : (
+ {
+ clearAllNavStates();
+ gtmTrack(GTMEvent.ClickHeadNav, {
+ item_name: label || alt,
+ });
+ if (isItem) {
+ (config as NavItemConfig).onClick?.();
+ }
+ }}
+ >
+
+ {startIcon}
+ {label}
+ {endIcon}
+
+
+ )
+ ) : (
+ // Render as button for group (with or without popover)
+
+ {startIcon && (
+
+ {startIcon}
+
+ )}
+ {label && (
+
+ {label}
+
+ )}
+ {shouldShowPopover && (
+
+ )}
+
+ )}
+ >
+ );
+};
diff --git a/src/components/Layout/Header/HeaderNavConfigData.tsx b/src/components/Layout/Header/HeaderNavConfigData.tsx
new file mode 100644
index 000000000..41819a470
--- /dev/null
+++ b/src/components/Layout/Header/HeaderNavConfigData.tsx
@@ -0,0 +1,183 @@
+import { NavConfig } from "./HeaderNavConfigType";
+import { CLOUD_MODE_KEY } from "shared/useCloudPlan";
+import { CloudPlan, TOCNamespace } from "shared/interface";
+import OpenInNewIcon from "@mui/icons-material/OpenInNew";
+
+import TiDBCloudIcon from "media/icons/cloud-03.svg";
+import TiDBIcon from "media/icons/layers-three-01.svg";
+
+/**
+ * Default navigation configuration
+ */
+const getDefaultNavConfig = (
+ cloudPlan: CloudPlan | null,
+ language?: string
+): NavConfig[] => [
+ {
+ type: "group",
+ title: "Product",
+ children: [
+ {
+ type: "group",
+ title: "TiDB Cloud",
+ titleIcon: ,
+ children: [
+ {
+ type: "item",
+ label: "TiDB Cloud Starter",
+ to: `/tidbcloud/starter?${CLOUD_MODE_KEY}=starter`,
+ selected: (namespace) =>
+ namespace === TOCNamespace.TiDBCloud &&
+ cloudPlan === CloudPlan.Starter,
+ onClick: () => {
+ if (typeof window !== "undefined") {
+ sessionStorage.setItem(CLOUD_MODE_KEY, "starter");
+ }
+ },
+ },
+ {
+ type: "item",
+ label: "TiDB Cloud Essential",
+ to: `/tidbcloud/essential?${CLOUD_MODE_KEY}=essential`,
+ selected: (namespace) =>
+ namespace === TOCNamespace.TiDBCloud &&
+ cloudPlan === CloudPlan.Essential,
+ onClick: () => {
+ if (typeof window !== "undefined") {
+ sessionStorage.setItem(CLOUD_MODE_KEY, "essential");
+ }
+ },
+ },
+ // {
+ // type: "item",
+ // label: "TiDB Cloud Premium",
+ // to: `/tidbcloud/premium?${CLOUD_MODE_KEY}=premium`,
+ // selected: (namespace) =>
+ // namespace === TOCNamespace.TiDBCloud &&
+ // cloudPlan === CloudPlan.Premium,
+ // onClick: () => {
+ // if (typeof window !== "undefined") {
+ // sessionStorage.setItem(CLOUD_MODE_KEY, "premium");
+ // }
+ // },
+ // },
+ {
+ type: "item",
+ label: "TiDB Cloud Dedicated",
+ to:
+ cloudPlan === "dedicated" || !cloudPlan
+ ? `/tidbcloud`
+ : `/tidbcloud?${CLOUD_MODE_KEY}=dedicated`,
+ selected: (namespace) =>
+ namespace === TOCNamespace.TiDBCloud &&
+ cloudPlan === CloudPlan.Dedicated,
+ onClick: () => {
+ if (typeof window !== "undefined") {
+ sessionStorage.setItem(CLOUD_MODE_KEY, "dedicated");
+ }
+ },
+ },
+ ],
+ },
+ {
+ type: "group",
+ title: "TiDB Self-Managed",
+ titleIcon: ,
+ children: [
+ {
+ type: "item",
+ label: "Deploy Using TiUP",
+ to: "/tidb/stable",
+ selected: (namespace) => namespace === TOCNamespace.TiDB,
+ },
+ {
+ type: "item",
+ label: "Deploy on Kubernetes",
+ to: "/tidb-in-kubernetes/stable",
+ selected: (namespace) =>
+ namespace === TOCNamespace.TiDBInKubernetes,
+ disabled: (lang: string) => lang === "ja",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: "item",
+ label: "Developer",
+ to: "/developer",
+ selected: (namespace) => namespace === TOCNamespace.Develop,
+ },
+ {
+ type: "item",
+ label: "Best Practices",
+ to: "/best-practices",
+ selected: (namespace) => namespace === TOCNamespace.BestPractices,
+ },
+ {
+ type: "item",
+ label: "API",
+ to: "/api",
+ selected: (namespace) => namespace === TOCNamespace.API,
+ },
+ {
+ type: "group",
+ title: "Releases",
+ children: [
+ {
+ type: "item",
+ label: "TiDB Cloud Releases",
+ to: "/releases/tidb-cloud",
+ selected: (namespace) => namespace === TOCNamespace.TidbCloudReleases,
+ },
+ {
+ type: "item",
+ label: "TiDB Self-Managed Releases",
+ to: "/releases/tidb-self-managed",
+ selected: (namespace) => namespace === TOCNamespace.TiDBReleases,
+ },
+ {
+ type: "item",
+ label: "TiDB Operator Releases",
+ to: "/releases/tidb-operator",
+ selected: (namespace) =>
+ namespace === TOCNamespace.TiDBInKubernetesReleases,
+ disabled: (lang: string) => lang === "ja",
+ },
+ {
+ type: "item",
+ label: "TiUP Releases",
+ to: "https://github.com/pingcap/tiup/releases",
+ selected: () => false,
+ endIcon: ,
+ },
+ ],
+ },
+];
+
+/**
+ * Archive navigation configuration (only TiDB Self-Managed)
+ */
+const archiveNavConfig: NavConfig[] = [
+ {
+ type: "item",
+ label: "TiDB Self-Managed",
+ to: "/tidb/v2.1",
+ selected: (namespace) => namespace === TOCNamespace.TiDB,
+ },
+];
+
+/**
+ * Generate navigation configuration
+ */
+export const generateNavConfig = (
+ t: (key: string) => string,
+ cloudPlan: CloudPlan | null,
+ buildType?: string,
+ language?: string
+): NavConfig[] => {
+ if (buildType === "archive") {
+ return archiveNavConfig;
+ }
+ return getDefaultNavConfig(cloudPlan, language);
+};
diff --git a/src/components/Layout/Header/HeaderNavConfigType.ts b/src/components/Layout/Header/HeaderNavConfigType.ts
new file mode 100644
index 000000000..841162373
--- /dev/null
+++ b/src/components/Layout/Header/HeaderNavConfigType.ts
@@ -0,0 +1,49 @@
+import { ReactNode } from "react";
+import { TOCNamespace } from "shared/interface";
+
+/**
+ * Single navigation item configuration
+ */
+export interface NavItemConfig {
+ type: "item";
+ /** Navigation label */
+ label: string | ReactNode;
+ /** Navigation URL */
+ to: string;
+ /** Optional icon before label */
+ startIcon?: ReactNode;
+ /** Optional icon after label */
+ endIcon?: ReactNode;
+ /** Optional alt text for GTM tracking */
+ alt?: string;
+ /** Whether this item is selected (can be a function that returns boolean) */
+ selected?: boolean | ((namespace?: TOCNamespace) => boolean);
+ /** Optional click handler */
+ onClick?: () => void;
+ /** Whether to use i18n for the link */
+ isI18n?: boolean;
+ /** Condition to show this item */
+ condition?: (language: string, buildType?: string) => boolean;
+ /** Whether this item is disabled (can be a function that returns boolean based on language) */
+ disabled?: boolean | ((language: string) => boolean);
+}
+
+/**
+ * Navigation group configuration
+ */
+export interface NavGroupConfig {
+ type: "group";
+ /** Group title (empty string means no title displayed) */
+ title: string | ReactNode;
+ /** Optional icon before title */
+ titleIcon?: ReactNode;
+ /** Children navigation items or nested groups */
+ children: (NavItemConfig | NavGroupConfig)[];
+ /** Condition to show this group */
+ condition?: (language: string, buildType?: string) => boolean;
+}
+
+/**
+ * Navigation configuration (either item or group)
+ */
+export type NavConfig = NavItemConfig | NavGroupConfig;
diff --git a/src/components/Layout/Header/HeaderNavMobile.tsx b/src/components/Layout/Header/HeaderNavMobile.tsx
new file mode 100644
index 000000000..108c964f5
--- /dev/null
+++ b/src/components/Layout/Header/HeaderNavMobile.tsx
@@ -0,0 +1,348 @@
+import * as React from "react";
+import { useI18next } from "gatsby-plugin-react-i18next";
+import Box from "@mui/material/Box";
+import Typography from "@mui/material/Typography";
+import Button from "@mui/material/Button";
+import { useTheme } from "@mui/material/styles";
+import Menu from "@mui/material/Menu";
+import MenuItem from "@mui/material/MenuItem";
+import Divider from "@mui/material/Divider";
+
+import LinkComponent from "components/Link";
+import ChevronDownIcon from "media/icons/chevron-down.svg";
+import { BuildType, TOCNamespace } from "shared/interface";
+import { GTMEvent, gtmTrack } from "shared/utils/gtm";
+
+import TiDBLogo from "media/logo/tidb-logo-withtext.svg";
+import { useCloudPlan } from "shared/useCloudPlan";
+import {
+ NavConfig,
+ NavItemConfig,
+ NavGroupConfig,
+} from "./HeaderNavConfigType";
+import { generateNavConfig } from "./HeaderNavConfigData";
+import { clearAllNavStates } from "../LeftNav/LeftNavTree";
+
+export function HeaderNavStackMobile(props: {
+ buildType?: BuildType;
+ namespace?: TOCNamespace;
+}) {
+ const [anchorEl, setAnchorEl] = React.useState(null);
+
+ const theme = useTheme();
+ const { language, t } = useI18next();
+ const { cloudPlan } = useCloudPlan();
+ const open = Boolean(anchorEl);
+ const handleClick = (event: React.MouseEvent) => {
+ setAnchorEl(event.currentTarget);
+ };
+ const handleClose = () => {
+ setAnchorEl(null);
+ };
+
+ // Generate navigation config
+ const navConfig: NavConfig[] = React.useMemo(() => {
+ return generateNavConfig(t, cloudPlan, props.buildType, language);
+ }, [t, cloudPlan, props.buildType, language]);
+
+ return (
+
+
+ }
+ endIcon={}
+ >
+
+
+ );
+}
+
+// Recursive component to render nav config (item or group)
+const RenderNavConfig = (props: {
+ config: NavConfig;
+ namespace?: TOCNamespace;
+ onClose: () => void;
+ language?: string;
+}) => {
+ const { config, namespace, onClose, language } = props;
+
+ if (config.type === "item") {
+ return (
+
+ );
+ }
+
+ // Handle group
+ if (config.type === "group") {
+ const groups = config.children.filter(
+ (child) => child.type === "group"
+ ) as NavGroupConfig[];
+ const items = config.children.filter(
+ (child) => child.type === "item"
+ ) as NavItemConfig[];
+
+ // Don't render if no children
+ if (groups.length === 0 && items.length === 0) {
+ return null;
+ }
+
+ return (
+ <>
+ {/* Render group title if it exists */}
+ {config.title && (
+
+ {config.titleIcon && (
+
+ {config.titleIcon}
+
+ )}
+
+ {config.title}
+
+
+ )}
+
+ {/* Render nested groups */}
+ {groups.map((group, groupIndex) => {
+ const groupItems = group.children.filter(
+ (child) => child.type === "item"
+ ) as NavItemConfig[];
+
+ if (groupItems.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {groupIndex > 0 && }
+ {group.title && (
+
+ {group.titleIcon && (
+
+ {group.titleIcon}
+
+ )}
+
+ {group.title}
+
+
+ )}
+ {groupItems.map((child, childIndex) => (
+
+ ))}
+
+ );
+ })}
+
+ {/* Render direct items */}
+ {items.map((item, itemIndex) => (
+
+ ))}
+ >
+ );
+ }
+
+ return null;
+};
+
+// Menu item component
+const NavMenuItem = (props: {
+ item: NavItemConfig;
+ namespace?: TOCNamespace;
+ onClose: () => void;
+ language?: string;
+}) => {
+ const { item, namespace, onClose, language } = props;
+ const isSelected =
+ typeof item.selected === "function"
+ ? item.selected(namespace)
+ : item.selected ?? false;
+
+ const isDisabled =
+ typeof item.disabled === "function"
+ ? item.disabled(language || "")
+ : item.disabled ?? false;
+
+ const menuItemContent = (
+
+ );
+
+ if (isDisabled) {
+ return menuItemContent;
+ }
+
+ return (
+ {
+ gtmTrack(GTMEvent.ClickHeadNav, {
+ item_name: item.label || item.alt,
+ });
+ }}
+ >
+ {menuItemContent}
+
+ );
+};
diff --git a/src/components/Layout/Header/LangSwitch.tsx b/src/components/Layout/Header/LangSwitch.tsx
new file mode 100644
index 000000000..325b96cf1
--- /dev/null
+++ b/src/components/Layout/Header/LangSwitch.tsx
@@ -0,0 +1,135 @@
+import * as React from "react";
+import { Trans, useI18next } from "gatsby-plugin-react-i18next";
+
+import Box from "@mui/material/Box";
+import Typography from "@mui/material/Typography";
+import Button from "@mui/material/Button";
+import Menu from "@mui/material/Menu";
+import MenuItem from "@mui/material/MenuItem";
+import IconButton from "@mui/material/IconButton";
+import { useTheme } from "@mui/material/styles";
+
+import TranslateIcon from "media/icons/globe-02.svg";
+import ChevronDownIcon from "media/icons/chevron-down.svg";
+
+import { Locale } from "shared/interface";
+
+const LANG_MAP = {
+ [Locale.en]: "English",
+ [Locale.zh]: "简体中文",
+ [Locale.ja]: "日本語",
+};
+
+export const LangSwitch = (props: {
+ language?: string;
+ changeLanguage?: () => void;
+ supportedLocales: Locale[];
+}) => {
+ const { supportedLocales } = props;
+
+ const [anchorEl, setAnchorEl] = React.useState(null);
+
+ const theme = useTheme();
+ const { language, changeLanguage } = useI18next();
+
+ const open = Boolean(anchorEl);
+ const handleClick = (event: React.MouseEvent) => {
+ setAnchorEl(event.currentTarget);
+ };
+ const handleClose = () => {
+ setAnchorEl(null);
+ };
+
+ const toggleLanguage = (locale: Locale) => () => {
+ changeLanguage(locale);
+ handleClose();
+ };
+
+ return (
+
+
+
+
+ }
+ endIcon={}
+ size="large"
+ sx={{
+ display: {
+ xs: "none",
+ lg: "inline-flex",
+ },
+ }}
+ >
+ {LANG_MAP[language as Locale]}
+
+
+
+ );
+};
diff --git a/src/components/Layout/Header/getSelectedNavItem.ts b/src/components/Layout/Header/getSelectedNavItem.ts
new file mode 100644
index 000000000..75ddec0dd
--- /dev/null
+++ b/src/components/Layout/Header/getSelectedNavItem.ts
@@ -0,0 +1,27 @@
+import { TOCNamespace } from "shared/interface";
+import { NavConfig, NavItemConfig } from "./HeaderNavConfigType";
+
+export const getSelectedNavItem = (
+ configs: NavConfig[],
+ namespace?: TOCNamespace
+): NavItemConfig | null => {
+ for (const config of configs) {
+ if (config.type === "item") {
+ const isSelected =
+ typeof config.selected === "function"
+ ? config.selected(namespace)
+ : config.selected ?? false;
+ if (isSelected) {
+ return config;
+ }
+ continue;
+ }
+
+ const item = getSelectedNavItem(config.children, namespace);
+ if (item) {
+ return item;
+ }
+ }
+ return null;
+};
+
diff --git a/src/components/Layout/Header/index.tsx b/src/components/Layout/Header/index.tsx
new file mode 100644
index 000000000..5ac19dacd
--- /dev/null
+++ b/src/components/Layout/Header/index.tsx
@@ -0,0 +1,511 @@
+import * as React from "react";
+import Box from "@mui/material/Box";
+import IconButton from "@mui/material/IconButton";
+import { useTheme } from "@mui/material/styles";
+
+import LinkComponent, { BlueAnchorLink } from "components/Link";
+import HeaderNavStack from "components/Layout/Header/HeaderNav";
+import { HeaderNavStackMobile } from "components/Layout/Header/HeaderNavMobile";
+import HeaderAction from "components/Layout/Header/HeaderAction";
+import { LangSwitch } from "components/Layout/Header/LangSwitch";
+
+import TiDBLogo from "media/logo/tidb-logo-withtext.svg";
+
+import { Locale, BuildType, PathConfig, TOCNamespace } from "shared/interface";
+import { GTMEvent, gtmTrack } from "shared/utils/gtm";
+import { Banner } from "components/Layout/Banner";
+import { generateDocsHomeUrl, generateUrl } from "shared/utils";
+import { useI18next } from "gatsby-plugin-react-i18next";
+import { useIsAutoTranslation } from "shared/useIsAutoTranslation";
+import { ErrorOutlineOutlined, ArrowUpward } from "@mui/icons-material";
+import { HEADER_HEIGHT } from "shared/headerHeight";
+
+import { NavItemConfig } from "./HeaderNavConfigType";
+
+interface HeaderProps {
+ bannerEnabled?: boolean;
+ children?: React.ReactNode;
+ menu?: React.ReactNode;
+ locales: Locale[];
+ docInfo?: { type: string; version: string };
+ buildType?: BuildType;
+ name?: string;
+ pathConfig?: PathConfig;
+ namespace: TOCNamespace;
+ onSelectedNavItemChange?: (item: NavItemConfig | null) => void;
+}
+
+const clamp = (value: number, min: number, max: number): number => {
+ return Math.min(max, Math.max(min, value));
+};
+
+const LOGO_GAP = 24;
+const CSS_VAR_TRANSLATE_X = "--pc-docs-header-translate-x";
+const CSS_VAR_LOGO_SCALE = "--pc-docs-header-logo-scale";
+
+export default function Header(props: HeaderProps) {
+ const { language } = useI18next();
+ const theme = useTheme();
+ const isAutoTranslation = useIsAutoTranslation(props.namespace);
+ const bannerVisible =
+ props.buildType === "archive" || isAutoTranslation || !!props.bannerEnabled;
+
+ const firstRowHeightPx = React.useMemo(() => {
+ return Number.parseInt(HEADER_HEIGHT.FIRST_ROW, 10);
+ }, []);
+
+ const cssVarRootRef = React.useRef(null);
+ const leftClusterRef = React.useRef(null);
+ const logoMeasureRef = React.useRef(null);
+ const leftClusterWidthRef = React.useRef(0);
+ const logoWidthRef = React.useRef(0);
+ const [showBackToTop, setShowBackToTop] = React.useState(false);
+ const showBackToTopRef = React.useRef(false);
+
+ const handleBackToTop = React.useCallback(() => {
+ if (typeof window === "undefined") {
+ return;
+ }
+ window.scrollTo({ top: 0, behavior: "smooth" });
+ }, []);
+
+ const syncScrollStyles = React.useCallback(() => {
+ if (typeof window === "undefined") {
+ return;
+ }
+ const root = cssVarRootRef.current;
+ if (!root) {
+ return;
+ }
+
+ const y = window.scrollY || 0;
+ const progress = clamp(y / firstRowHeightPx, 0, 1);
+
+ const logoWidth = logoWidthRef.current;
+ const leftClusterWidth = leftClusterWidthRef.current;
+ if (logoWidth === 0 || leftClusterWidth === 0) {
+ root.style.setProperty(CSS_VAR_TRANSLATE_X, "0px");
+ root.style.setProperty(CSS_VAR_LOGO_SCALE, "1");
+ return;
+ }
+ const logoScale = 1 - progress * 0.2;
+
+ const menuWidth = Math.max(0, leftClusterWidth - logoWidth);
+ const translateX =
+ progress * (menuWidth + logoWidth * logoScale + LOGO_GAP);
+
+ root.style.setProperty(CSS_VAR_TRANSLATE_X, `${translateX}px`);
+ root.style.setProperty(CSS_VAR_LOGO_SCALE, `${logoScale}`);
+ }, [firstRowHeightPx]);
+
+ const updateLeftClusterSizes = React.useCallback(() => {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ const clusterElement = leftClusterRef.current;
+ if (clusterElement) {
+ const rect = clusterElement.getBoundingClientRect();
+ if (rect.width !== 0) {
+ leftClusterWidthRef.current = rect.width;
+ }
+ }
+
+ const logoElement = logoMeasureRef.current;
+ if (logoElement) {
+ const rect = logoElement.getBoundingClientRect();
+ if (rect.width !== 0) {
+ logoWidthRef.current = rect.width;
+ }
+ }
+ syncScrollStyles();
+ }, [firstRowHeightPx, syncScrollStyles]);
+
+ React.useEffect(() => {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ let rafId: number | null = null;
+ const measure = () => {
+ updateLeftClusterSizes();
+ };
+ rafId = window.requestAnimationFrame(measure);
+ window.addEventListener("resize", measure);
+
+ return () => {
+ if (rafId != null) {
+ window.cancelAnimationFrame(rafId);
+ }
+ window.removeEventListener("resize", measure);
+ };
+ }, [updateLeftClusterSizes]);
+
+ React.useEffect(() => {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ let ticking = false;
+ let rafId: number | null = null;
+ const update = () => {
+ syncScrollStyles();
+ const y = window.scrollY || 0;
+ const shouldShowBackToTop = y >= firstRowHeightPx;
+ if (shouldShowBackToTop !== showBackToTopRef.current) {
+ showBackToTopRef.current = shouldShowBackToTop;
+ setShowBackToTop(shouldShowBackToTop);
+ }
+ ticking = false;
+ };
+
+ update();
+ const onScroll = () => {
+ if (ticking) {
+ return;
+ }
+ ticking = true;
+ rafId = window.requestAnimationFrame(update);
+ };
+ window.addEventListener("scroll", onScroll, { passive: true });
+
+ return () => {
+ if (rafId != null) {
+ window.cancelAnimationFrame(rafId);
+ }
+ window.removeEventListener("scroll", onScroll);
+ };
+ }, [syncScrollStyles]);
+
+ return (
+
+ {bannerVisible && (
+
+
+
+ )}
+
+
+
+ {props.menu && (
+
+ {props.menu}
+
+ )}
+
+
+ gtmTrack(GTMEvent.ClickHeadNav, {
+ item_name: "logo",
+ })
+ }
+ >
+
+
+
+
+
+
+
+ {/* First row: Logo and HeaderAction */}
+
+
+ {props.menu}
+
+
+ gtmTrack(GTMEvent.ClickHeadNav, {
+ item_name: "logo",
+ })
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+ {/* Second row: sticky */}
+
+ {props.menu && (
+
+ {props.menu}
+
+ )}
+
+
+
+
+
+
+
+
+
+ {showBackToTop && (
+
+
+
+ )}
+ {props.locales.length > 0 && (
+
+ )}
+
+
+
+ {props.children}
+
+
+ );
+}
+
+const HeaderBanner = (props: HeaderProps) => {
+ const { t } = useI18next();
+ const isAutoTranslation = useIsAutoTranslation(props.namespace);
+ const urlAutoTranslation =
+ props.pathConfig?.repo === "tidbcloud"
+ ? `/tidbcloud/${props.name === "_index" ? "" : props.name}`
+ : `/${props.pathConfig?.repo}/${props.pathConfig?.version || "stable"}/${
+ props.name === "_index" ? "" : props.name
+ }`;
+
+ let archivedTargetUrl = "";
+ if (props.name && props.pathConfig) {
+ const stableCfg = { ...props.pathConfig, version: "stable" };
+ const path = generateUrl(props.name, stableCfg);
+ archivedTargetUrl = `https://docs.pingcap.com${path}`;
+ } else {
+ const lang =
+ props.pathConfig?.locale === Locale.en
+ ? ""
+ : `/${props.pathConfig?.locale}`;
+ archivedTargetUrl = `https://docs.pingcap.com${lang}/tidb/stable/`;
+ }
+
+ if (props.buildType === "archive") {
+ return (
+
+ }
+ textList={[
+ t("banner.archive.title"),
+
+ {t("banner.archive.viewLatestLTSVersion")} ↗
+ ,
+ ]}
+ />
+ );
+ }
+
+ if (isAutoTranslation) {
+ return (
+
+ // }
+ logo={"📣"}
+ textList={[
+
+ {t("banner.autoTrans.title1")}
+ ,
+ t("banner.autoTrans.title2"),
+
+ {t("banner.autoTrans.end")}
+ ,
+ ]}
+ />
+ );
+ }
+
+ return props.bannerEnabled ? (
+
+ {t("banner.campaign.title1")}
+ ,
+ t("banner.campaign.title2"),
+
+ {t("banner.campaign.end")}
+ ,
+ ]}
+ />
+ ) : null;
+};
diff --git a/src/components/Layout/HeaderAction.tsx b/src/components/Layout/HeaderAction.tsx
deleted file mode 100644
index d14c675e9..000000000
--- a/src/components/Layout/HeaderAction.tsx
+++ /dev/null
@@ -1,329 +0,0 @@
-import * as React from "react";
-import { Trans, useI18next } from "gatsby-plugin-react-i18next";
-
-import Box from "@mui/material/Box";
-import Typography from "@mui/material/Typography";
-import Stack from "@mui/material/Stack";
-import { useTheme } from "@mui/material/styles";
-import Button from "@mui/material/Button";
-import Menu from "@mui/material/Menu";
-import MenuItem from "@mui/material/MenuItem";
-import IconButton from "@mui/material/IconButton";
-import StarIcon from "media/icons/star.svg";
-
-import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
-import CloudIcon from "@mui/icons-material/Cloud";
-import TranslateIcon from "media/icons/globe-02.svg";
-
-import Search from "components/Search";
-
-import { Locale, BuildType } from "shared/interface";
-import { ActionButton } from "components/Card/FeedbackSection/components";
-import { Link } from "gatsby";
-import { useIsAutoTranslation } from "shared/useIsAutoTranslation";
-
-const useTiDBAIStatus = () => {
- const [showTiDBAIButton, setShowTiDBAIButton] = React.useState(true);
- const [initializingTiDBAI, setInitializingTiDBAI] = React.useState(true);
-
- React.useEffect(() => {
- if (!!window.tidbai) {
- setInitializingTiDBAI(false);
- }
-
- const onTiDBAIInitialized = () => {
- setInitializingTiDBAI(false);
- };
- const onTiDBAIError = () => {
- setInitializingTiDBAI(false);
- setShowTiDBAIButton(false);
- };
- window.addEventListener("tidbaiinitialized", onTiDBAIInitialized);
- window.addEventListener("tidbaierror", onTiDBAIError);
-
- const timer = setTimeout(() => {
- if (!window.tidbai) {
- setInitializingTiDBAI(false);
- setShowTiDBAIButton(false);
- }
- }, 10000);
- return () => {
- clearTimeout(timer);
- window.removeEventListener("tidbaiinitialized", onTiDBAIInitialized);
- window.removeEventListener("tidbaierror", onTiDBAIError);
- };
- }, []);
-
- return { showTiDBAIButton, initializingTiDBAI };
-};
-
-export default function HeaderAction(props: {
- supportedLocales: Locale[];
- docInfo?: { type: string; version: string };
- buildType?: BuildType;
- pageUrl?: string;
-}) {
- const { supportedLocales, docInfo, buildType, pageUrl } = props;
- const { language, t } = useI18next();
- const { showTiDBAIButton, initializingTiDBAI } = useTiDBAIStatus();
- const isAutoTranslation = useIsAutoTranslation(pageUrl || "");
-
- return (
-
- {supportedLocales.length > 0 && (
-
- )}
- {docInfo && !isAutoTranslation && buildType !== "archive" && (
- <>
-
-
- {language === "en" && showTiDBAIButton && (
- }
- disabled={initializingTiDBAI}
- sx={{
- display: {
- xs: "none",
- xl: "flex",
- },
- }}
- onClick={() => {
- window.tidbai.open = true;
- }}
- >
- Ask AI
-
- )}
-
- >
- )}
- {language === "en" && }
-
- );
-}
-
-const LANG_MAP = {
- [Locale.en]: "EN",
- [Locale.zh]: "中文",
- [Locale.ja]: "日本語",
-};
-
-const LangSwitch = (props: {
- language?: string;
- changeLanguage?: () => void;
- supportedLocales: Locale[];
-}) => {
- const { supportedLocales } = props;
-
- const [anchorEl, setAnchorEl] = React.useState(null);
-
- const theme = useTheme();
- const { language, changeLanguage } = useI18next();
-
- const open = Boolean(anchorEl);
- const handleClick = (event: React.MouseEvent) => {
- setAnchorEl(event.currentTarget);
- };
- const handleClose = () => {
- setAnchorEl(null);
- };
-
- const toggleLanguage = (locale: Locale) => () => {
- changeLanguage(locale);
- handleClose();
- };
-
- return (
-
-
-
-
- }
- endIcon={
-
- }
- sx={{
- display: {
- xs: "none",
- lg: "inline-flex",
- },
- }}
- >
- {LANG_MAP[language as Locale]}
-
-
-
- );
-};
-
-const TiDBCloudBtnGroup = () => {
- const [anchorEl, setAnchorEl] = React.useState(null);
- const open = Boolean(anchorEl);
- const handleClick = (event: React.MouseEvent) => {
- setAnchorEl(event.currentTarget);
- };
- const handleClose = () => {
- setAnchorEl(null);
- };
-
- return (
- <>
-
-
-
-
-
-
-
- >
- );
-};
diff --git a/src/components/Layout/HeaderNav.tsx b/src/components/Layout/HeaderNav.tsx
deleted file mode 100644
index d39503015..000000000
--- a/src/components/Layout/HeaderNav.tsx
+++ /dev/null
@@ -1,361 +0,0 @@
-import * as React from "react";
-import { Trans, useI18next } from "gatsby-plugin-react-i18next";
-import Box from "@mui/material/Box";
-import Typography from "@mui/material/Typography";
-import Stack from "@mui/material/Stack";
-import Button from "@mui/material/Button";
-import { useTheme } from "@mui/material/styles";
-import Menu from "@mui/material/Menu";
-import MenuItem from "@mui/material/MenuItem";
-import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
-import DownloadIcon from "@mui/icons-material/Download";
-
-import LinkComponent from "components/Link";
-import {
- generateDownloadURL,
- generateContactURL,
- generateLearningCenterURL,
- generateDocsHomeUrl,
- getPageType,
- PageType,
-} from "shared/utils";
-import { BuildType } from "shared/interface";
-import { GTMEvent, gtmTrack } from "shared/utils/gtm";
-
-import TiDBLogo from "media/logo/tidb-logo-withtext.svg";
-import { CLOUD_MODE_KEY, useCloudPlan } from "shared/useCloudPlan";
-
-// `pageUrl` comes from server side render (or build): gatsby/path.ts/generateUrl
-// it will be `undefined` in client side render
-const useSelectedNavItem = (language?: string, pageUrl?: string) => {
- // init in server side
- const [selectedItem, setSelectedItem] = React.useState(
- () => getPageType(language, pageUrl) || "home"
- );
-
- // update in client side
- React.useEffect(() => {
- setSelectedItem(getPageType(language, window.location.pathname));
- }, [language]);
-
- return selectedItem;
-};
-
-export default function HeaderNavStack(props: {
- buildType?: BuildType;
- pageUrl?: string;
-}) {
- const { language, t } = useI18next();
- const selectedItem = useSelectedNavItem(language, props.pageUrl);
- const { cloudPlan } = useCloudPlan();
-
- return (
-
- {props.buildType !== "archive" && (
-
- )}
-
-
-
- {["zh"].includes(language) && (
-
- )}
-
- {["en", "ja"].includes(language) && (
-
- )}
-
-
-
- {language === "zh" && (
- }
- to={generateDownloadURL(language)}
- alt="download"
- startIcon={}
- />
- )}
-
- );
-}
-
-const NavItem = (props: {
- selected?: boolean;
- label?: string | React.ReactElement;
- to: string;
- startIcon?: React.ReactNode;
- alt?: string;
- onClick?: () => void;
-}) => {
- const theme = useTheme();
- return (
- <>
-
- {
- gtmTrack(GTMEvent.ClickHeadNav, {
- item_name: props.label || props.alt,
- });
-
- props.onClick?.();
- }}
- >
-
- {props.startIcon}
- {props.label}
-
-
-
- >
- );
-};
-
-export function HeaderNavStackMobile(props: { buildType?: BuildType }) {
- const [anchorEl, setAnchorEl] = React.useState(null);
-
- const theme = useTheme();
- const { language, t } = useI18next();
- const selectedItem = useSelectedNavItem(language);
- const { cloudPlan } = useCloudPlan();
- const open = Boolean(anchorEl);
- const handleClick = (event: React.MouseEvent) => {
- setAnchorEl(event.currentTarget);
- };
- const handleClose = () => {
- setAnchorEl(null);
- };
-
- return (
-
- }
- endIcon={}
- >
-
-
- );
-}
diff --git a/src/components/Layout/LeftNav/LeftNav.tsx b/src/components/Layout/LeftNav/LeftNav.tsx
index 1bd372928..0a439a3ea 100644
--- a/src/components/Layout/LeftNav/LeftNav.tsx
+++ b/src/components/Layout/LeftNav/LeftNav.tsx
@@ -1,22 +1,24 @@
import * as React from "react";
-import { useI18next } from "gatsby-plugin-react-i18next";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Drawer from "@mui/material/Drawer";
import Divider from "@mui/material/Divider";
+import Typography from "@mui/material/Typography";
+import { useTheme } from "@mui/material/styles";
import IconButton from "@mui/material/IconButton";
import MenuIcon from "@mui/icons-material/Menu";
-import { RepoNav, PathConfig, BuildType } from "shared/interface";
+import { RepoNav, PathConfig, BuildType, TOCNamespace } from "shared/interface";
+import { NavItemConfig } from "../Header/HeaderNavConfigType";
import LinkComponent from "components/Link";
-import LeftNavTree from "./LeftNavTree";
+import LeftNavTree, { clearAllNavStates } from "./LeftNavTree";
import VersionSelect, {
NativeVersionSelect,
} from "../VersionSelect/VersionSelect";
+import { getHeaderStickyHeight } from "shared/headerHeight";
import TiDBLogoWithoutText from "media/logo/tidb-logo.svg";
-import CloudVersionSelect from "../VersionSelect/CloudVersionSelect";
interface LeftNavProps {
data: RepoNav;
@@ -27,6 +29,9 @@ interface LeftNavProps {
buildType?: BuildType;
bannerEnabled?: boolean;
availablePlans: string[];
+ selectedNavItem?: NavItemConfig | null;
+ language?: string;
+ namespace?: TOCNamespace;
}
export function LeftNavDesktop(props: LeftNavProps) {
@@ -37,8 +42,10 @@ export function LeftNavDesktop(props: LeftNavProps) {
pathConfig,
availIn,
buildType,
- availablePlans,
+ selectedNavItem,
+ namespace,
} = props;
+ const theme = useTheme();
return (
- {pathConfig.repo !== "tidbcloud" && (
-
+ {selectedNavItem && (
+
+ {
+ clearAllNavStates();
+ }}
+ >
+
+ {selectedNavItem.label}
+
+
+
)}
- {pathConfig.repo === "tidbcloud" && (
-
)}
@@ -87,12 +121,11 @@ export function LeftNavDesktop(props: LeftNavProps) {
}
export function LeftNavMobile(props: LeftNavProps) {
- const { data, current, name, pathConfig, availIn, buildType } = props;
+ const { data, current, name, pathConfig, availIn, buildType, namespace } =
+ props;
const [open, setOpen] = React.useState(false);
- const { language } = useI18next();
-
const toggleDrawer =
(open: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => {
if (
@@ -119,7 +152,7 @@ export function LeftNavMobile(props: LeftNavProps) {
-
+
- {pathConfig.repo !== "tidbcloud" && (
+ {(namespace === TOCNamespace.TiDB ||
+ namespace === TOCNamespace.TiDBInKubernetes) && (
;
- labelInfo?: string;
- labelText?: string;
-};
-
-const StyledTreeItemRoot = styled(TreeItem)(({ theme }) => ({
- [`& .${treeItemClasses.content}`]: {
- color: theme.palette.website.f1,
- "&:hover": {
- backgroundColor: theme.palette.carbon[200],
- },
- "&.Mui-selected, &.Mui-selected.Mui-focused, &.Mui-selected:hover": {
- backgroundColor: theme.palette.carbon[300],
- color: theme.palette.secondary.main,
- [`& svg.MuiTreeItem-ChevronRightIcon`]: {
- fill: theme.palette.carbon[700],
- },
- },
- "&.Mui-focused": {
- backgroundColor: `#f9f9f9`,
- },
- [`& .${treeItemClasses.label}`]: {
- fontWeight: "inherit",
- color: "inherit",
- paddingLeft: 0,
- },
- [`& .${treeItemClasses.iconContainer}`]: {
- display: "none",
- },
- },
- [`& .${treeItemClasses.group}`]: {
- marginLeft: 0,
- },
-}));
-
-function StyledTreeItem(props: StyledTreeItemProps) {
- const {
- bgColor,
- color,
- labelIcon: LabelIcon,
- labelInfo,
- labelText,
- ...other
- } = props;
-
- return (
-
- );
-}
-
const calcExpandedIds = (
data: RepoNavLink[],
targetLink: string,
@@ -99,6 +40,10 @@ const calcExpandedIds = (
// Session storage key prefix for nav item id
const NAV_ITEM_ID_STORAGE_KEY = "nav_item_id_";
+// Session storage key prefix for scroll position
+const NAV_SCROLL_POSITION_STORAGE_KEY = "nav_scroll_position_";
+// Session storage key prefix for expanded tree nodes
+const NAV_EXPANDED_IDS_STORAGE_KEY = "nav_expanded_ids_";
// Get nav item id from session storage for a given path
const getNavItemIdFromStorage = (path: string): string | null => {
@@ -120,6 +65,113 @@ const saveNavItemIdToStorage = (path: string, id: string): void => {
}
};
+// Get scroll position from session storage for a given path
+const getScrollPositionFromStorage = (path: string): number | null => {
+ if (typeof window === "undefined") return null;
+ try {
+ const value = sessionStorage.getItem(
+ `${NAV_SCROLL_POSITION_STORAGE_KEY}${path}`
+ );
+ return value ? parseInt(value, 10) : null;
+ } catch {
+ return null;
+ }
+};
+
+// Save scroll position to session storage for a given path
+const saveScrollPositionToStorage = (path: string, scrollTop: number): void => {
+ if (typeof window === "undefined") return;
+ try {
+ sessionStorage.setItem(
+ `${NAV_SCROLL_POSITION_STORAGE_KEY}${path}`,
+ scrollTop.toString()
+ );
+ } catch {
+ // Ignore storage errors
+ }
+};
+
+// Get expanded IDs from session storage for a given path
+const getExpandedIdsFromStorage = (path: string): string[] | null => {
+ if (typeof window === "undefined") return null;
+ try {
+ const value = sessionStorage.getItem(
+ `${NAV_EXPANDED_IDS_STORAGE_KEY}${path}`
+ );
+ return value ? JSON.parse(value) : null;
+ } catch {
+ return null;
+ }
+};
+
+// Save expanded IDs to session storage for a given path
+const saveExpandedIdsToStorage = (
+ path: string,
+ expandedIds: string[]
+): void => {
+ if (typeof window === "undefined") return;
+ try {
+ sessionStorage.setItem(
+ `${NAV_EXPANDED_IDS_STORAGE_KEY}${path}`,
+ JSON.stringify(expandedIds)
+ );
+ } catch {
+ // Ignore storage errors
+ }
+};
+
+// Get the scrollable container element
+const getScrollableContainer = (): HTMLElement | null => {
+ if (typeof document === "undefined") return null;
+ const treeView = document.querySelector("#left-nav-treeview");
+ if (!treeView) return null;
+
+ // Find the nearest scrollable parent
+ let parent = treeView.parentElement;
+ while (parent) {
+ const style = window.getComputedStyle(parent);
+ if (style.overflowY === "auto" || style.overflowY === "scroll") {
+ return parent;
+ }
+ parent = parent.parentElement;
+ }
+ return null;
+};
+
+// Clear all navigation state from session storage for a given path
+export const clearNavState = (path: string): void => {
+ if (typeof window === "undefined") return;
+ try {
+ sessionStorage.removeItem(`${NAV_ITEM_ID_STORAGE_KEY}${path}`);
+ sessionStorage.removeItem(`${NAV_SCROLL_POSITION_STORAGE_KEY}${path}`);
+ sessionStorage.removeItem(`${NAV_EXPANDED_IDS_STORAGE_KEY}${path}`);
+ } catch {
+ // Ignore storage errors
+ }
+};
+
+// Clear all navigation states from session storage (for all paths)
+export const clearAllNavStates = (): void => {
+ if (typeof window === "undefined") return;
+ try {
+ const keysToRemove: string[] = [];
+ for (let i = 0; i < sessionStorage.length; i++) {
+ const key = sessionStorage.key(i);
+ if (
+ key &&
+ (key.startsWith(NAV_ITEM_ID_STORAGE_KEY) ||
+ key.startsWith(NAV_SCROLL_POSITION_STORAGE_KEY) ||
+ key.startsWith(NAV_EXPANDED_IDS_STORAGE_KEY))
+ ) {
+ keysToRemove.push(key);
+ }
+ }
+ keysToRemove.forEach((key) => sessionStorage.removeItem(key));
+ } catch {
+ // Ignore storage errors
+ }
+};
+
export default function ControlledTreeView(props: {
data: RepoNav;
current: string;
@@ -138,16 +190,43 @@ export default function ControlledTreeView(props: {
});
const theme = useTheme();
+ const [disableTransition, setDisableTransition] = React.useState(false);
+ const previousUrlRef = React.useRef(null);
React.useEffect(() => {
const storedId = getNavItemIdFromStorage(currentUrl);
- const expandedIds = calcExpandedIds(
- data,
- currentUrl,
- storedId || undefined
- );
+ // Try to get saved expanded IDs first
+ const savedExpandedIds = getExpandedIdsFromStorage(currentUrl);
+
+ let expandedIds: string[];
+ let selectedId: string | undefined;
+ const isUrlChanged = previousUrlRef.current !== currentUrl;
+ previousUrlRef.current = currentUrl;
+
+ if (savedExpandedIds && savedExpandedIds.length > 0) {
+ // Use saved expanded IDs if available
+ expandedIds = savedExpandedIds;
+ // Use storedId for selected if available, otherwise use the last expanded ID
+ selectedId = storedId || undefined;
+
+ // Disable transition animation only when restoring saved state and URL changed
+ if (isUrlChanged) {
+ setDisableTransition(true);
+ // Re-enable transitions after a short delay
+ setTimeout(() => {
+ setDisableTransition(false);
+ }, 100);
+ }
+ } else {
+ // Fallback to calculating from current URL
+ expandedIds = calcExpandedIds(data, currentUrl, storedId || undefined);
+ selectedId = storedId || undefined;
+ }
+
setExpanded(expandedIds);
- expandedIds.length && setSelected([expandedIds[expandedIds.length - 1]]);
+ if (selectedId) {
+ setSelected([selectedId]);
+ }
}, [data, currentUrl]);
// ! Add "auto scroll" to left nav is not recommended.
@@ -156,26 +235,43 @@ export default function ControlledTreeView(props: {
| (HTMLElement & { scrollIntoViewIfNeeded: () => void })
| null = document?.querySelector(".MuiTreeView-root .Mui-selected");
if (targetActiveItem) {
- scrollToElementIfInView(targetActiveItem);
+ // Check if there's a saved scroll position for this URL
+ const savedScrollPosition = getScrollPositionFromStorage(currentUrl);
+ const scrollContainer = getScrollableContainer();
+
+ if (savedScrollPosition !== null && scrollContainer) {
+ // Restore scroll position
+ scrollContainer.scrollTop = savedScrollPosition;
+ } else {
+ // Fallback to original behavior
+ scrollToElementIfInView(targetActiveItem);
+ }
}
- }, [selected]);
+ }, [selected, currentUrl]);
const renderNavs = (items: RepoNavLink[]) => {
return items.map((item) => {
if (item.type === "heading") {
return (
-
- {item.content[0] as string}
-
+
+ {item.content[0] as string}
+
+
+
);
} else {
return renderTreeItems([item]);
@@ -231,10 +327,22 @@ export default function ControlledTreeView(props: {
// Save nav item id to session storage when clicked
if (item.link) {
saveNavItemIdToStorage(item.link, item.id);
+
+ // Save scroll position to session storage
+ const scrollContainer = getScrollableContainer();
+ if (scrollContainer) {
+ saveScrollPositionToStorage(
+ item.link,
+ scrollContainer.scrollTop
+ );
+ }
+
+ // Save expanded IDs to session storage
+ saveExpandedIdsToStorage(item.link, expanded);
}
}}
>
- }
ContentProps={{
@@ -244,7 +352,7 @@ export default function ControlledTreeView(props: {
{hasChildren
? renderTreeItems(item.children as RepoNavLink[], deepth + 1)
: null}
-
+
);
});
@@ -252,6 +360,10 @@ export default function ControlledTreeView(props: {
const handleToggle = (event: React.SyntheticEvent, nodeIds: string[]) => {
setExpanded(nodeIds);
+ // Save expanded IDs to session storage when toggled
+ if (currentUrl) {
+ saveExpandedIdsToStorage(currentUrl, nodeIds);
+ }
};
return (
@@ -261,6 +373,16 @@ export default function ControlledTreeView(props: {
expanded={expanded}
selected={selected}
onNodeToggle={handleToggle}
+ sx={{
+ ...(disableTransition && {
+ "& .MuiTreeItem-group": {
+ transition: "none !important",
+ },
+ "& .MuiCollapse-root": {
+ transition: "none !important",
+ },
+ }),
+ }}
>
{renderNavs(data)}
@@ -286,6 +408,7 @@ const generateItemLabel = ({ content: contents, tag }: RepoNavLink) => {
const c = isContentString ? content : content.value;
return (
calcPDFUrl(pathConfig), [pathConfig]);
- const pageType = React.useMemo(
- () => getPageType(language, pageUrl),
- [pageUrl]
- );
-
- // ! TOREMOVED
- const { site } = useStaticQuery(graphql`
- query {
- site {
- siteMetadata {
- siteUrl
- }
- }
- }
- `);
- let { pathname } = useLocation();
- if (pathname.endsWith("/")) {
- pathname = pathname.slice(0, -1); // unify client and ssr
- }
-
- // Track active heading for scroll highlighting
- const [activeId, setActiveId] = React.useState("");
-
- React.useEffect(() => {
- // Collect all heading IDs from the TOC
- const headingIds: string[] = [];
- const collectIds = (items: TableOfContent[]) => {
- items.forEach((item) => {
- if (item.url) {
- const id = item.url.replace(/^#/, "");
- if (id) {
- headingIds.push(id);
- }
- }
- if (item?.items) {
- collectIds(item.items);
- }
- });
- };
- collectIds(toc);
-
- if (headingIds.length === 0) return;
-
- // Create an intersection observer
- const observer = new IntersectionObserver(
- (entries) => {
- entries.forEach((entry) => {
- if (entry.isIntersecting) {
- setActiveId(entry.target.id);
- }
- });
- },
- {
- rootMargin: "-80px 0px -80% 0px",
- threshold: 0,
- }
- );
-
- setTimeout(() => {
- // Observe all heading elements
- headingIds.forEach((id) => {
- const element = document.getElementById(id);
- if (element) {
- observer.observe(element);
- }
- });
- }, 1000);
-
- // Cleanup
- return () => {
- headingIds.forEach((id) => {
- const element = document.getElementById(id);
- if (element) {
- observer.unobserve(element);
- }
- });
- };
- }, [toc]);
-
- return (
- <>
-
- {language !== "ja" && (
-
- {pageType !== "tidbcloud" && (
-
- )}
- {buildType !== "archive" && (
-
- )}
- {buildType !== "archive" &&
- ["zh", "en"].includes(pathConfig.locale) && (
-
- )}
- {buildType !== "archive" && pathConfig.version === "dev" && (
-
- )}
-
- )}
-
-
-
-
-
- {generateToc(toc, 0, activeId)}
-
-
- >
- );
-}
-
-const generateToc = (items: TableOfContent[], level = 0, activeId = "") => {
- const theme = useTheme();
-
- return (
-
- {items.map((item) => {
- const { url, title, items } = item;
- const { label: newLabel, anchor: newAnchor } = transformCustomId(
- title,
- url
- );
- const itemId = url?.replace(/^#/, "") || "";
- const isActive = itemId && itemId === activeId;
-
- return (
-
-
- {removeHtmlTag(newLabel)}
-
- {items && generateToc(items, level + 1, activeId)}
-
- );
- })}
-
- );
-};
-
-const ActionItem = (props: {
- url: string;
- label: string;
- icon?: typeof SvgIcon;
- [key: string]: any;
-}) => {
- const { url, label, sx, ...rest } = props;
- const theme = useTheme();
- return (
-
- {props.icon && (
-
- )}
- {label}
-
- );
-};
-
-export function RightNavMobile(props: RightNavProps) {
- const { toc = [], pathConfig, filePath, buildType } = props;
-
- const [anchorEl, setAnchorEl] = React.useState(null);
- const open = Boolean(anchorEl);
- const handleClick = (event: React.MouseEvent) => {
- setAnchorEl(event.currentTarget);
- };
- const handleClose = () => {
- setAnchorEl(null);
- };
-
- const generateMobileTocList = (items: TableOfContent[], level = 0) => {
- const result: { label: string; anchor: string; depth: number }[] = [];
- items.forEach((item) => {
- const { url, title, items: children } = item;
- const { label: newLabel, anchor: newAnchor } = transformCustomId(
- title,
- url
- );
- result.push({
- label: newLabel,
- anchor: newAnchor,
- depth: level,
- });
- if (children) {
- const childrenresult = generateMobileTocList(children, level + 1);
- result.push(...childrenresult);
- }
- });
- return result;
- };
-
- return (
-
- }
- sx={{
- width: "100%",
- }}
- >
-
-
-
-
- );
-}
diff --git a/src/components/Layout/RightNav/RightNav.tsx b/src/components/Layout/RightNav/RightNav.tsx
index f84810146..0af9f4a07 100644
--- a/src/components/Layout/RightNav/RightNav.tsx
+++ b/src/components/Layout/RightNav/RightNav.tsx
@@ -7,11 +7,11 @@ import { useTheme } from "@mui/material/styles";
import Button from "@mui/material/Button";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
-import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
-
import { TableOfContent, PathConfig, BuildType } from "shared/interface";
+import ChevronDownIcon from "media/icons/chevron-down.svg";
import { transformCustomId, removeHtmlTag } from "shared/utils";
import { sliceVersionMark } from "shared/utils/anchor";
+import { getHeaderStickyHeight } from "shared/headerHeight";
interface RightNavProps {
toc?: TableOfContent[];
@@ -96,11 +96,11 @@ export default function RightNav(props: RightNavProps) {
{
paddingTop: "0.25rem",
paddingBottom: "0.25rem",
fontWeight: isActive ? "700" : "400",
- color: isActive ? theme.palette.website.f1 : "inherit",
"&:hover": {
- color: theme.palette.website.f3,
- borderLeft: `1px solid ${theme.palette.website.f3}`,
+ fontWeight: "700",
},
}}
>
@@ -225,7 +223,7 @@ export function RightNavMobile(props: RightNavProps) {
aria-haspopup="true"
aria-expanded={open ? "true" : undefined}
onClick={handleClick}
- endIcon={}
+ endIcon={}
sx={{
width: "100%",
}}
diff --git a/src/components/Layout/TitleAction/TitleAction.tsx b/src/components/Layout/TitleAction/TitleAction.tsx
index 8001d49f9..95b403391 100644
--- a/src/components/Layout/TitleAction/TitleAction.tsx
+++ b/src/components/Layout/TitleAction/TitleAction.tsx
@@ -10,15 +10,14 @@ import { useTheme } from "@mui/material/styles";
import Button from "@mui/material/Button";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
-import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
-
import EditIcon from "media/icons/edit.svg";
import CopyIcon from "media/icons/copy.svg";
import MarkdownIcon from "media/icons/markdown.svg";
import FileIcon from "media/icons/file.svg";
+import ChevronDownIcon from "media/icons/chevron-down.svg";
-import { BuildType, PathConfig } from "shared/interface";
-import { calcPDFUrl, getPageType, getRepoFromPathCfg } from "shared/utils";
+import { BuildType, PathConfig, TOCNamespace } from "shared/interface";
+import { calcPDFUrl, getRepoFromPathCfg } from "shared/utils";
import { Tooltip, Divider } from "@mui/material";
interface TitleActionProps {
@@ -27,20 +26,18 @@ interface TitleActionProps {
pageUrl: string;
buildType: BuildType;
language: string;
+ namespace?: TOCNamespace;
}
export const TitleAction = (props: TitleActionProps) => {
- const { pathConfig, filePath, pageUrl, buildType, language } = props;
+ const { pathConfig, filePath, pageUrl, buildType, language, namespace } =
+ props;
const { t } = useI18next();
const theme = useTheme();
const [contributeAnchorEl, setContributeAnchorEl] =
React.useState(null);
const [copied, setCopied] = React.useState(false);
const isArchive = buildType === "archive";
- const pageType = React.useMemo(
- () => getPageType(language, pageUrl),
- [pageUrl]
- );
const contributeOpen = Boolean(contributeAnchorEl);
@@ -145,7 +142,7 @@ export const TitleAction = (props: TitleActionProps) => {
onClick={handleContributeClick}
startIcon={}
endIcon={
-
}
@@ -249,7 +246,7 @@ export const TitleAction = (props: TitleActionProps) => {
)}
{/* Download PDF */}
- {pageType === "tidb" && language !== "ja" && (
+ {namespace === TOCNamespace.TiDB && language !== "ja" && (
}
diff --git a/src/components/Layout/VersionSelect/SharedSelect.tsx b/src/components/Layout/VersionSelect/SharedSelect.tsx
index 525185810..64f9c8e65 100644
--- a/src/components/Layout/VersionSelect/SharedSelect.tsx
+++ b/src/components/Layout/VersionSelect/SharedSelect.tsx
@@ -23,7 +23,6 @@ export const VersionSelectButton = forwardRef(
position: "sticky",
top: "-20px",
backgroundColor: "#fff",
- marginTop: "-20px",
marginLeft: "-16px",
marginRight: "-16px",
paddingTop: "20px",
diff --git a/src/components/Layout/VersionSelect/VersionSelect.tsx b/src/components/Layout/VersionSelect/VersionSelect.tsx
index 436464f41..57e76066c 100644
--- a/src/components/Layout/VersionSelect/VersionSelect.tsx
+++ b/src/components/Layout/VersionSelect/VersionSelect.tsx
@@ -71,10 +71,12 @@ const VersionItems = (props: {
return (
<>
-
+
{pathConfig.repo === "tidb" && (
Long-Term Support
@@ -108,9 +109,11 @@ const VersionItems = (props: {
)}
{LTSVersions.map((version) => (
{DMRVersions.map((version) => (
))}
-
+
>
)}
>
)}
- {/*
- {headingsMemo.map((i) => (
- -
- {i.label}
-
- ))}
-
*/}
)}
diff --git a/src/components/MDXComponents/EmailSubscriptionForm.tsx b/src/components/MDXComponents/EmailSubscriptionForm.tsx
index 697654e76..aeea524e9 100644
--- a/src/components/MDXComponents/EmailSubscriptionForm.tsx
+++ b/src/components/MDXComponents/EmailSubscriptionForm.tsx
@@ -104,6 +104,7 @@ function EmailSubscriptionForm() {
a.MuiCardActionArea-root:hover, a.MuiButton-text:hover": {
textDecoration: "none",
},
- ".MuiCardActions-root > a.MuiButton-text": {
+ ".MuiCardActions-root > a.MuiButton-text:not(.button)": {
color: "text.secondary",
borderRadius: 0,
},
diff --git a/src/components/MDXContent.tsx b/src/components/MDXContent.tsx
index 87ccfc3ea..ebef8ed93 100644
--- a/src/components/MDXContent.tsx
+++ b/src/components/MDXContent.tsx
@@ -8,11 +8,15 @@ import Box from "@mui/material/Box";
import * as MDXComponents from "components/MDXComponents";
import { CustomNotice } from "components/Card/CustomNotice";
-import { PathConfig, BuildType, CloudPlan } from "shared/interface";
+import {
+ PathConfig,
+ BuildType,
+ CloudPlan,
+ TOCNamespace,
+} from "shared/interface";
import replaceInternalHref from "shared/utils/anchor";
import { Pre } from "components/MDXComponents/Pre";
import { useCustomContent } from "components/MDXComponents/CustomContent";
-import { getPageType } from "shared/utils";
import { H1 } from "./MDXComponents/H1";
export default function MDXContent(props: {
@@ -26,6 +30,7 @@ export default function MDXContent(props: {
buildType: BuildType;
pageUrl: string;
cloudPlan: CloudPlan | null;
+ namespace?: TOCNamespace;
}) {
const {
data,
@@ -38,10 +43,14 @@ export default function MDXContent(props: {
buildType,
pageUrl,
cloudPlan,
+ namespace,
} = props;
- const pageType = getPageType(language, pageUrl);
- const CustomContent = useCustomContent(pageType, cloudPlan, language);
+ const CustomContent = useCustomContent(
+ namespace || TOCNamespace.TiDB,
+ cloudPlan,
+ language
+ );
// const isAutoTranslation = useIsAutoTranslation(pageUrl || "");
React.useEffect(() => {
@@ -67,7 +76,7 @@ export default function MDXContent(props: {
{...props}
/>
),
- [pathConfig, filePath, pageUrl]
+ [pathConfig, filePath, pageUrl, namespace]
);
return (
diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx
index b5afefe8e..608c7e668 100644
--- a/src/components/Search/index.tsx
+++ b/src/components/Search/index.tsx
@@ -31,7 +31,7 @@ const StyledTextField = styled((props: TextFieldProps) => (
},
}));
-const SEARCH_WIDTH = 250;
+const SEARCH_WIDTH = 400;
enum SearchType {
Onsite = "onsite",
diff --git a/src/media/icons/chevron-down.svg b/src/media/icons/chevron-down.svg
new file mode 100644
index 000000000..b912fc636
--- /dev/null
+++ b/src/media/icons/chevron-down.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/media/icons/cloud-03.svg b/src/media/icons/cloud-03.svg
new file mode 100644
index 000000000..c9d24ae71
--- /dev/null
+++ b/src/media/icons/cloud-03.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/media/icons/layers-three-01.svg b/src/media/icons/layers-three-01.svg
new file mode 100644
index 000000000..97af7b2c1
--- /dev/null
+++ b/src/media/icons/layers-three-01.svg
@@ -0,0 +1,10 @@
+
diff --git a/src/media/logo/tidb-logo-withtext.svg b/src/media/logo/tidb-logo-withtext.svg
index 28bd4072a..1225ad09a 100644
--- a/src/media/logo/tidb-logo-withtext.svg
+++ b/src/media/logo/tidb-logo-withtext.svg
@@ -1,17 +1,17 @@
-