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: `` }, - ] + { type: "jsx", value: `` }, + ]; } 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)} > - - + )} {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 && ( + + )} + + )} + {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 */} + + + + + + Sign In + + { + handleClose(); + }} + component={Link} + to={`https://tidbcloud.com/free-trial`} + target="_blank" + referrerPolicy="no-referrer-when-downgrade" + sx={{ + textDecoration: "none", + }} + > + Try Free + + + + ); +}; 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; + clearAllNavStates(); + onClose(); + item.onClick?.(); + }} + disableRipple + selected={isSelected} + disabled={isDisabled} + sx={{ + padding: groupTitle ? "10px 12px" : "8px 12px", + opacity: isDisabled ? 0.5 : 1, + cursor: isDisabled ? "not-allowed" : "pointer", + }} + > + + {item.startIcon && ( + + {item.startIcon} + + )} + + {item.label} + + {item.endIcon && ( + + {item.endIcon} + + )} + + + ); + + 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 ( + + + + {navConfig + .filter((config) => { + // Filter out configs that don't meet condition + if ( + config.condition && + !config.condition(language, props.buildType) + ) { + return false; + } + return true; + }) + .flatMap((config, index) => { + const items: React.ReactNode[] = []; + if (index > 0) { + items.push(); + } + items.push( + + ); + return items; + })} + + + ); +} + +// 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; + clearAllNavStates(); + onClose(); + item.onClick?.(); + }} + disableRipple + selected={isSelected} + disabled={isDisabled} + sx={{ + padding: "10px 16px", + opacity: isDisabled ? 0.5 : 1, + cursor: isDisabled ? "not-allowed" : "pointer", + }} + > + + {item.startIcon && ( + + {item.startIcon} + + )} + + {item.label} + + {item.endIcon && ( + + {item.endIcon} + + )} + + + ); + + 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 ( + + + + + + + + + + + + + + + + + + + + + + + + ); +}; 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 ( - - - - - - - - - - - - - - - - - - - - - - - - ); -}; - -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 ( - <> - - - - - - - - - - - Sign In - - { - handleClose(); - }} - component={Link} - to={`https://tidbcloud.com/free-trial`} - target="_blank" - referrerPolicy="no-referrer-when-downgrade" - sx={{ - textDecoration: "none", - }} - > - Try Free - - - - ); -}; 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 ( - - - - {["en", "zh"].includes(language) && ( - - - gtmTrack(GTMEvent.ClickHeadNav, { - item_name: "home", - }) - } - > - - {props.buildType === "archive" ? ( - - ) : ( - - )} - - - - )} - - {props.buildType !== "archive" && ( - - - gtmTrack(GTMEvent.ClickHeadNav, { - item_name: t("navbar.cloud"), - }) - } - > - - - - - - )} - - - - gtmTrack(GTMEvent.ClickHeadNav, { - item_name: t("navbar.tidb"), - }) - } - > - - - - - - - {language === "zh" && ( - - - gtmTrack(GTMEvent.ClickHeadNav, { - item_name: t("navbar.download"), - }) - } - > - - - - - - )} - - {["ja", "en"].includes(language) && ( - - - gtmTrack(GTMEvent.ClickHeadNav, { - item_name: t("navbar.learningCenter"), - }) - } - > - - - - - - )} - - {["zh"].includes(language) && ( - - - gtmTrack(GTMEvent.ClickHeadNav, { - item_name: t("navbar.asktug"), - }) - } - > - - - - - - )} - - - - gtmTrack(GTMEvent.ClickHeadNav, { - item_name: t("navbar.contactUs"), - }) - } - > - - - - - - - - ); -} 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 ( - - - - {generateMobileTocList(toc).map((item) => { - return ( - - - {item.label} - - - ); - })} - - - ); -} 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" && (