diff --git a/docs b/docs index 631b8250e..59aad6c4a 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 631b8250ef16f47f73c2387221b9d8a18107f7f1 +Subproject commit 59aad6c4afb90f7395671e90485ff4295b932a81 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/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..b1f91009d 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]; + 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/__tests__/link-resolver.test.ts b/gatsby/link-resolver/__tests__/link-resolver.test.ts new file mode 100644 index 000000000..d5aa96141 --- /dev/null +++ b/gatsby/link-resolver/__tests__/link-resolver.test.ts @@ -0,0 +1,757 @@ +/** + * 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("/develop/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("/develop/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("/develop/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("/develop/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("/develop/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/develop/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/develop/vector-search-data-types"); + }); + + it("should resolve best-practice namespace links (en - default language omitted)", () => { + const result = resolveMarkdownLink( + "/best-practice/optimization/query-optimization", + "/en/tidb/stable/alert-rules" + ); + expect(result).toBe("/best-practice/query-optimization"); + }); + + it("should resolve best-practice namespace links (zh - language prefix included)", () => { + const result = resolveMarkdownLink( + "/best-practice/optimization/query-optimization", + "/zh/tidb/stable/alert-rules" + ); + expect(result).toBe("/zh/best-practice/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 releases/tidb links (en - default language omitted)", () => { + const result = resolveMarkdownLink( + "/releases/tidb", + "/en/tidb/stable/alert-rules" + ); + expect(result).toBe("/releases/tidb"); + }); + + it("should resolve releases/tidb links (zh - language prefix included)", () => { + const result = resolveMarkdownLink( + "/releases/tidb", + "/zh/tidb/stable/alert-rules" + ); + expect(result).toBe("/zh/releases/tidb"); + }); + + it("should resolve releases/tidb-cloud links (en - default language omitted)", () => { + const result = resolveMarkdownLink( + "/releases/tidb-cloud", + "/en/tidb/stable/alert-rules" + ); + expect(result).toBe("/releases/tidb-cloud"); + }); + + it("should resolve releases/tidb-cloud links (zh - language prefix included)", () => { + const result = resolveMarkdownLink( + "/releases/tidb-cloud", + "/zh/tidb/stable/alert-rules" + ); + expect(result).toBe("/zh/releases/tidb-cloud"); + }); + + it("should resolve releases/tidb-operator links (en - default language omitted)", () => { + const result = resolveMarkdownLink( + "/releases/tidb-operator", + "/en/tidb/stable/alert-rules" + ); + expect(result).toBe("/releases/tidb-operator"); + }); + + it("should resolve releases/tidb-operator links (zh - language prefix included)", () => { + const result = resolveMarkdownLink( + "/releases/tidb-operator", + "/zh/tidb/stable/alert-rules" + ); + 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("/develop/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/develop/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: develop/best-practice/api/releases namespace pages", () => { + it("should resolve links from develop namespace page", () => { + const result = resolveMarkdownLink( + "/vector-search/vector-search-overview", + "/en/develop/vector-search" + ); + expect(result).toBe("/tidb/stable/vector-search-overview"); + }); + + it("should resolve links from best-practice namespace page", () => { + const result = resolveMarkdownLink( + "/optimization/query-optimization", + "/en/best-practice/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 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/develop/vector-search/data-types" + ); + expect(result).toBe("/tidb/stable/vector-search-data-types-overview"); + }); + + it("should resolve links with multiple path segments from best-practice namespace", () => { + const result = resolveMarkdownLink( + "/optimization/query/query-performance-tuning", + "/en/best-practice/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/develop/vector-search" + ); + expect(result).toBe("/tidb/stable/vector-search-overview#data-types"); + }); + + it("should preserve hash for links from best-practice namespace", () => { + const result = resolveMarkdownLink( + "/optimization/query-optimization#index-selection", + "/en/best-practice/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/develop/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/develop/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/develop/vector-search" + ); + expect(result).toBe("/tidb/stable/page-name"); + }); + + it("should resolve links from develop namespace root", () => { + const result = resolveMarkdownLink( + "/vector-search-overview", + "/en/develop" + ); + 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/develop/vector-search" + ); + expect(result).toBe("/tidb/stable/vector-search-overview"); + }); + + it("should resolve links from best-practice namespace with deep nesting", () => { + const result = resolveMarkdownLink( + "/a/b/c/d/e/page", + "/en/best-practice/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-practice/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("/develop/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/develop/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/develop/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/develop/vector-search + // But "tidb" is not the default language "en", so it's included + expect(result).toBe("/tidb/develop/vector-search"); + }); + + it("should use curLang in releases/tidb target pattern", () => { + const result = resolveMarkdownLink( + "/releases/tidb", + "/zh/tidb/stable/alert-rules" + ); + expect(result).toBe("/zh/releases/tidb"); + }); + + it("should use curLang in releases/tidb-cloud target pattern", () => { + const result = resolveMarkdownLink( + "/releases/tidb-cloud", + "/ja/tidb/stable/alert-rules" + ); + expect(result).toBe("/ja/releases/tidb-cloud"); + }); + + 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("/develop/vector-search"); + }); + + it("should handle links without trailing slash", () => { + const result = resolveMarkdownLink( + "/develop/vector-search", + "/en/tidb/stable/alert-rules" + ); + expect(result).toBe("/develop/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("/develop/vector-search"); + }); + + it("should handle links with multiple leading slashes", () => { + const result = resolveMarkdownLink( + "///develop/vector-search", + "/en/tidb/stable/alert-rules" + ); + expect(result).toBe("/develop/vector-search"); + }); + + it("should handle empty path segments", () => { + const result = resolveMarkdownLink( + "/develop//vector-search", + "/en/tidb/stable/alert-rules" + ); + expect(result).toBe("/develop/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("/develop/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("/develop/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..6eb78714d --- /dev/null +++ b/gatsby/link-resolver/config.ts @@ -0,0 +1,68 @@ +/** + * 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/tidb", + targetPattern: "/{curLang}/releases/tidb", + }, + { + linkPattern: "/releases/tidb-cloud", + targetPattern: "/{curLang}/releases/tidb-cloud", + }, + { + linkPattern: "/releases/tidb-operator", + 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 + { + linkPattern: "/{namespace}/{...any}/{docname}", + targetPattern: "/{curLang}/{namespace}/{docname}", + conditions: { + namespace: ["tidb-cloud", "develop", "best-practice", "api"], + }, + namespaceTransform: { + "tidb-cloud": "tidbcloud", + }, + }, + // 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: develop, best-practice, api, releases namespace in tidb folder + // Current page: /{lang}/{namespace}/{...any} + // Link: /{...any}/{docname} -> /{lang}/{namespace}/{docname} + { + pathPattern: `/{lang}/{namespace}/{...any}`, + pathConditions: { + namespace: ["develop", "best-practice", "api"], + }, + 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..20930bc4f --- /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.BestPractice, + repo: Repo.tidb, + branch: CONFIG.docs.tidb.stable, + folder: "best-practice", + 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 => { + 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 TOCNamespace.TiDB; +}; 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/__tests__/README.md b/gatsby/url-resolver/__tests__/README.md new file mode 100644 index 000000000..1fe9b946b --- /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-practice/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..b25855982 --- /dev/null +++ b/gatsby/url-resolver/__tests__/url-resolver.test.ts @@ -0,0 +1,586 @@ +/** + * 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/develop/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/develop/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-practice folder", () => { + const absolutePath = path.join( + sourceBasePath, + "en/tidb/release-8.5/best-practice/guide.md" + ); + const url = calculateFileUrlWithConfig(absolutePath, testConfig); + expect(url).toBe("/en/best-practice/guide/"); + }); + + 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/"); + }); + + 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 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("/develop/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..4213d5329 --- /dev/null +++ b/gatsby/url-resolver/config.ts @@ -0,0 +1,139 @@ +/** + * 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 + { + sourcePattern: `/{lang}/tidb/${CONFIG.docs.tidb.stable}/releases/{filename}`, + targetPattern: "/{lang}/releases/tidb", + 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, best-practice, api, releases namespace in tidb folder + // When filename = "_index": /en/tidb/master/develop/{folders}/_index.md -> /en/develop/{folders}/ + // When filename != "_index": /en/tidb/master/develop/{folders}/{filename}.md -> /en/develop/{filename}/ + { + sourcePattern: `/{lang}/tidb/${CONFIG.docs.tidb.stable}/{folder}/{...folders}/{filename}`, + targetPattern: "/{lang}/{folder}/{filename}", + conditions: { + folder: ["develop", "best-practice", "api"], + }, + 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..f59297721 100644 --- a/src/components/Layout/Banner/Banner.tsx +++ b/src/components/Layout/Banner/Banner.tsx @@ -1,4 +1,5 @@ import { Box, Divider, Stack, Typography } from "@mui/material"; +import { HEADER_HEIGHT } from "shared/headerHeight"; export function Banner({ url, @@ -17,14 +18,12 @@ export function Banner({ { + 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 [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..62469ecf6 --- /dev/null +++ b/src/components/Layout/Header/HeaderNav.tsx @@ -0,0 +1,538 @@ +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"; + +// Helper function to find selected item recursively +const findSelectedItem = ( + 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; + } + } else if (config.type === "group" && config.children) { + const item = findSelectedItem(config.children, namespace); + if (item) { + return item; + } + } + } + return null; +}; + +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); + }, [props.config, props.buildType, cloudPlan, t]); + + // Find and notify selected item + React.useEffect(() => { + if (props.onSelectedNavItemChange) { + const selectedNavItem = findSelectedItem(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 }) => { + 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; + } + }; + + 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; +}) => { + const { item, groupTitle, namespace, onClose } = props; + const isSelected = + typeof item.selected === "function" + ? item.selected(namespace) + : item.selected ?? false; + + return ( + { + gtmTrack(GTMEvent.ClickHeadNav, { + item_name: item.label || item.alt, + }); + }} + > + { + clearAllNavStates(); + onClose(); + item.onClick?.(); + }} + disableRipple + selected={isSelected} + sx={{ + padding: groupTitle ? "10px 12px" : "8px 12px", + }} + > + + {item.startIcon && ( + + {item.startIcon} + + )} + + {item.label} + + + + + ); +}; + +// 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; +}) => { + const { + config, + isItem, + selected, + hasSelectedChild, + shouldShowPopover, + open, + onMouseEnter, + onMouseLeave, + } = 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 alt = isItem ? (config as NavItemConfig).alt : undefined; + const isI18n = isItem ? (config as NavItemConfig).isI18n ?? true : true; + + // Determine selected state for border styling + const isSelectedState = isItem ? selected : hasSelectedChild; + + return ( + <> + {isItem && to ? ( + // Render as link for item + { + clearAllNavStates(); + gtmTrack(GTMEvent.ClickHeadNav, { + item_name: label || alt, + }); + if (isItem) { + (config as NavItemConfig).onClick?.(); + } + }} + > + + {startIcon} + {label} + + + ) : ( + // 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..211852b5b --- /dev/null +++ b/src/components/Layout/Header/HeaderNavConfigData.tsx @@ -0,0 +1,175 @@ +import { NavConfig } from "./HeaderNavConfigType"; +import { CLOUD_MODE_KEY } from "shared/useCloudPlan"; +import { CloudPlan, TOCNamespace } from "shared/interface"; + +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): 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: "TiDB Self-Managed", + to: "/tidb/stable", + selected: (namespace) => namespace === TOCNamespace.TiDB, + }, + { + type: "item", + label: "TiDB Self-Managed on Kubernetes", + to: "/tidb-in-kubernetes/stable", + selected: (namespace) => + namespace === TOCNamespace.TiDBInKubernetes, + }, + ], + }, + ], + }, + { + type: "item", + label: "Developer", + to: "/develop", + selected: (namespace) => namespace === TOCNamespace.Develop, + }, + { + type: "item", + label: "Best Practices", + to: "/best-practice", + selected: (namespace) => namespace === TOCNamespace.BestPractice, + }, + { + 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", + selected: (namespace) => namespace === TOCNamespace.TiDBReleases, + }, + { + type: "item", + label: "TiDB Operator Releases", + to: "/releases/tidb-operator", + selected: (namespace) => + namespace === TOCNamespace.TiDBInKubernetesReleases, + }, + { + type: "item", + label: "TiUP Releases", + to: "https://github.com/pingcap/tiup/releases", + selected: () => false, + }, + ], + }, +]; + +/** + * 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 +): NavConfig[] => { + if (buildType === "archive") { + return archiveNavConfig; + } + return getDefaultNavConfig(cloudPlan); +}; diff --git a/src/components/Layout/Header/HeaderNavConfigType.ts b/src/components/Layout/Header/HeaderNavConfigType.ts new file mode 100644 index 000000000..668bc933f --- /dev/null +++ b/src/components/Layout/Header/HeaderNavConfigType.ts @@ -0,0 +1,45 @@ +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 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; +} + +/** + * 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..b84929dfd --- /dev/null +++ b/src/components/Layout/Header/HeaderNavMobile.tsx @@ -0,0 +1,310 @@ +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); + }, [t, cloudPlan, props.buildType]); + + return ( + + + + {navConfig + .filter((config) => { + // Filter out configs that don't meet condition + if ( + config.condition && + !config.condition(language, props.buildType) + ) { + return false; + } + return true; + }) + .map((config, index, filteredArray) => { + return ( + + {index > 0 && } + + + ); + })} + + + ); +} + +// Recursive component to render nav config (item or group) +const RenderNavConfig = (props: { + config: NavConfig; + namespace?: TOCNamespace; + onClose: () => void; +}) => { + const { config, namespace, onClose } = 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; +}) => { + const { item, namespace, onClose } = props; + const isSelected = + typeof item.selected === "function" + ? item.selected(namespace) + : item.selected ?? false; + + return ( + { + gtmTrack(GTMEvent.ClickHeadNav, { + item_name: item.label || item.alt, + }); + }} + > + { + clearAllNavStates(); + onClose(); + item.onClick?.(); + }} + disableRipple + selected={isSelected} + sx={{ + padding: "10px 16px", + }} + > + + {item.startIcon && ( + + {item.startIcon} + + )} + + {item.label} + + + + + ); +}; diff --git a/src/components/Layout/Header/LangSwitch.tsx b/src/components/Layout/Header/LangSwitch.tsx new file mode 100644 index 000000000..0ba034368 --- /dev/null +++ b/src/components/Layout/Header/LangSwitch.tsx @@ -0,0 +1,136 @@ +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.tsx b/src/components/Layout/Header/index.tsx similarity index 61% rename from src/components/Layout/Header.tsx rename to src/components/Layout/Header/index.tsx index 106a4fa93..34a9c075a 100644 --- a/src/components/Layout/Header.tsx +++ b/src/components/Layout/Header/index.tsx @@ -5,20 +5,23 @@ 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 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 } from "shared/interface"; +import { Locale, BuildType, PathConfig, TOCNamespace } from "shared/interface"; import { GTMEvent, gtmTrack } from "shared/utils/gtm"; -import { Banner } from "./Banner"; +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 } from "@mui/icons-material"; +import { getHeaderHeight, HEADER_HEIGHT } from "shared/headerHeight"; + +import { NavItemConfig } from "./HeaderNavConfigType"; interface HeaderProps { bannerEnabled?: boolean; @@ -26,9 +29,10 @@ interface HeaderProps { locales: Locale[]; docInfo?: { type: string; version: string }; buildType?: BuildType; - pageUrl?: string; name?: string; pathConfig?: PathConfig; + namespace: TOCNamespace; + onSelectedNavItemChange?: (item: NavItemConfig | null) => void; } export default function Header(props: HeaderProps) { @@ -43,7 +47,7 @@ export default function Header(props: HeaderProps) { zIndex: 9, backgroundColor: "carbon.50", boxShadow: "none", - height: props.bannerEnabled ? "7.5rem" : "5rem", + height: getHeaderHeight(props.bannerEnabled || false), }} > @@ -53,39 +57,77 @@ export default function Header(props: HeaderProps) { height: "100%", paddingLeft: "24px", paddingRight: "24px", + flexDirection: { + xs: "column-reverse", + md: "column", + }, + alignItems: "stretch", borderBottom: `1px solid ${theme.palette.carbon[400]}`, }} > - {props.menu} + {/* First row: Logo and HeaderAction */} - - gtmTrack(GTMEvent.ClickHeadNav, { - item_name: "logo", - }) - } + {props.menu} + - - + + gtmTrack(GTMEvent.ClickHeadNav, { + item_name: "logo", + }) + } + > + + + + + - - + {/* Second row: HeaderNavStack, HeaderNavStackMobile, and LangSwitch */} + + + - + {props.locales.length > 0 && ( + + + + )} + ); @@ -93,7 +135,7 @@ export default function Header(props: HeaderProps) { const HeaderBanner = (props: HeaderProps) => { const { t } = useI18next(); - const isAutoTranslation = useIsAutoTranslation(props.pageUrl || ""); + const isAutoTranslation = useIsAutoTranslation(props.namespace); const urlAutoTranslation = props.pathConfig?.repo === "tidbcloud" ? `/tidbcloud/${props.name === "_index" ? "" : props.name}` 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..75b26d18f 100644 --- a/src/components/Layout/LeftNav/LeftNav.tsx +++ b/src/components/Layout/LeftNav/LeftNav.tsx @@ -4,19 +4,22 @@ 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 { getHeaderHeight } from "shared/headerHeight"; import TiDBLogoWithoutText from "media/logo/tidb-logo.svg"; -import CloudVersionSelect from "../VersionSelect/CloudVersionSelect"; interface LeftNavProps { data: RepoNav; @@ -27,6 +30,9 @@ interface LeftNavProps { buildType?: BuildType; bannerEnabled?: boolean; availablePlans: string[]; + selectedNavItem?: NavItemConfig | null; + language?: string; + namespace?: TOCNamespace; } export function LeftNavDesktop(props: LeftNavProps) { @@ -37,8 +43,10 @@ export function LeftNavDesktop(props: LeftNavProps) { pathConfig, availIn, buildType, - availablePlans, + selectedNavItem, + namespace, } = props; + const theme = useTheme(); return ( - {pathConfig.repo !== "tidbcloud" && ( + {selectedNavItem && ( + + { + clearAllNavStates(); + }} + > + + {selectedNavItem.label} + + + + )} + + {(namespace === TOCNamespace.TiDB || + namespace === TOCNamespace.TiDBInKubernetes) && ( )} - {pathConfig.repo === "tidbcloud" && ( + {/* {pathConfig.repo === "tidbcloud" && ( - )} + )} */} diff --git a/src/components/Layout/LeftNav/LeftNavTree.tsx b/src/components/Layout/LeftNav/LeftNavTree.tsx index 4f033ae86..0cced50ae 100644 --- a/src/components/Layout/LeftNav/LeftNavTree.tsx +++ b/src/components/Layout/LeftNav/LeftNavTree.tsx @@ -2,76 +2,17 @@ import * as React from "react"; import Box from "@mui/material/Box"; import ChevronRightIcon from "@mui/icons-material/ChevronRight"; import TreeView from "@mui/lab/TreeView"; -import TreeItem, { TreeItemProps, treeItemClasses } from "@mui/lab/TreeItem"; +import TreeItem from "@mui/lab/TreeItem"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; -import { styled, useTheme } from "@mui/material/styles"; -import { SvgIconProps } from "@mui/material/SvgIcon"; +import Divider from "@mui/material/Divider"; +import { useTheme } from "@mui/material/styles"; import { RepoNavLink, RepoNav } from "shared/interface"; import LinkComponent from "components/Link"; import { scrollToElementIfInView } from "shared/utils"; import { alpha, Chip } from "@mui/material"; -type StyledTreeItemProps = TreeItemProps & { - bgColor?: string; - color?: string; - labelIcon?: React.ElementType; - 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)} diff --git a/src/components/Layout/RightNav/RightNav.bk.tsx b/src/components/Layout/RightNav/RightNav.bk.tsx deleted file mode 100644 index 91d8294a1..000000000 --- a/src/components/Layout/RightNav/RightNav.bk.tsx +++ /dev/null @@ -1,396 +0,0 @@ -import * as React from "react"; -import { Trans, useI18next } from "gatsby-plugin-react-i18next"; -import { graphql, useStaticQuery } from "gatsby"; -import { useLocation } from "@reach/router"; -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 KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; -import SimCardDownloadIcon from "@mui/icons-material/SimCardDownload"; -import QuestionAnswerIcon from "@mui/icons-material/QuestionAnswer"; -import EditIcon from "@mui/icons-material/Edit"; -import GitHubIcon from "@mui/icons-material/GitHub"; -import SvgIcon from "@mui/material/SvgIcon"; - -import { TableOfContent, PathConfig, BuildType } from "shared/interface"; -import { - calcPDFUrl, - getRepoFromPathCfg, - transformCustomId, - removeHtmlTag, -} from "shared/utils"; -import { sliceVersionMark } from "shared/utils/anchor"; -import { getPageType } from "shared/utils"; - -interface RightNavProps { - toc?: TableOfContent[]; - pathConfig: PathConfig; - filePath: string; - buildType?: BuildType; - pageUrl?: string; - bannerVisible?: boolean; -} - -export default function RightNav(props: RightNavProps) { - const { - toc = [], - pathConfig, - filePath, - buildType, - pageUrl, - bannerVisible, - } = props; - - const theme = useTheme(); - const { language, t } = useI18next(); - - const pdfUrlMemo = React.useMemo(() => 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..d9ab39827 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 { getHeaderHeight } from "shared/headerHeight"; interface RightNavProps { toc?: TableOfContent[]; @@ -96,11 +96,9 @@ export default function RightNav(props: RightNavProps) { } + 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" && (