From b37e8b09757062df62dc32e894f81d2b29932341 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Thu, 18 Dec 2025 15:15:02 +0800 Subject: [PATCH 01/37] feat: implement new LeftNav and RightNav components for improved navigation - Introduced LeftNavDesktop and LeftNavMobile components to enhance the layout and navigation experience. - Added RightNav and RightNavMobile components for better accessibility and organization of content. - Integrated a new H1 component with TitleAction for enhanced header functionality. - Updated translation strings and styles for consistency across the application. - Removed the deprecated Contributors component to streamline the codebase. --- locale/en/translation.json | 2 +- src/components/Contributors/index.tsx | 251 ---------------- .../{Navigation => LeftNav}/LeftNav.tsx | 0 .../{Navigation => LeftNav}/LeftNavTree.tsx | 0 .../RightNav.tsx => RightNav/RightNav.bk.tsx} | 0 src/components/Layout/RightNav/RightNav.tsx | 269 ++++++++++++++++++ .../Layout/TitleAction/TitleAction.tsx | 242 ++++++++++++++++ src/components/MDXComponents/H1.module.css | 3 + .../MDXComponents/H1.module.css.d.ts | 2 + src/components/MDXComponents/H1.tsx | 22 ++ src/components/MDXContent.tsx | 27 +- src/media/icons/copy.svg | 3 + src/media/icons/edit.svg | 3 + src/media/icons/file.svg | 3 + src/media/icons/markdown.svg | 3 + src/styles/docTemplate.css | 1 + src/templates/DocTemplate.tsx | 7 +- src/theme/index.tsx | 6 +- 18 files changed, 574 insertions(+), 270 deletions(-) delete mode 100644 src/components/Contributors/index.tsx rename src/components/Layout/{Navigation => LeftNav}/LeftNav.tsx (100%) rename src/components/Layout/{Navigation => LeftNav}/LeftNavTree.tsx (100%) rename src/components/Layout/{Navigation/RightNav.tsx => RightNav/RightNav.bk.tsx} (100%) create mode 100644 src/components/Layout/RightNav/RightNav.tsx create mode 100644 src/components/Layout/TitleAction/TitleAction.tsx create mode 100644 src/components/MDXComponents/H1.module.css create mode 100644 src/components/MDXComponents/H1.module.css.d.ts create mode 100644 src/components/MDXComponents/H1.tsx create mode 100644 src/media/icons/copy.svg create mode 100644 src/media/icons/edit.svg create mode 100644 src/media/icons/file.svg create mode 100644 src/media/icons/markdown.svg diff --git a/locale/en/translation.json b/locale/en/translation.json index 5efb5d0d..7a8afee4 100644 --- a/locale/en/translation.json +++ b/locale/en/translation.json @@ -40,7 +40,7 @@ }, "doc": { "notExist": "This doc does not exist", - "toc": "What's on this page", + "toc": "ON THIS PAGE", "download-pdf": "Download PDF", "improve": "Edit this page", "feedback": "Request docs changes", diff --git a/src/components/Contributors/index.tsx b/src/components/Contributors/index.tsx deleted file mode 100644 index 1a9a360f..00000000 --- a/src/components/Contributors/index.tsx +++ /dev/null @@ -1,251 +0,0 @@ -import * as React from "react"; -import * as ReactDOM from "react-dom"; -import axios from "axios"; -import Avatar from "@mui/material/Avatar"; -import AvatarGroup from "@mui/material/AvatarGroup"; -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import Menu from "@mui/material/Menu"; -import MenuItem from "@mui/material/MenuItem"; -import Button from "@mui/material/Button"; - -import { PathConfig } from "shared/interface"; -import { getRepo } from "../../../gatsby/path"; -import { ThemeProvider } from "@mui/material"; -import theme from "theme/index"; -import { Link } from "gatsby"; - -export interface TotalAvatarsProps { - avatars: AvatarItem[]; -} - -export type AvatarItem = { - src: string; - alt: string; - id: string; - name: string; - pageUrl: string; -}; - -export default function TotalAvatars(props: TotalAvatarsProps) { - const { avatars } = props; - const [anchorEl, setAnchorEl] = React.useState(null); - - const open = Boolean(anchorEl); - const handleClick = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; - const handleClose = () => { - setAnchorEl(null); - }; - - return ( - - - - {avatars.map((avatar, index) => ( - - ))} - - - - - {avatars.map((avatar) => ( - - - - {avatar.name} - - - ))} - - - ); -} - -export const useTotalContributors = ( - pathConfig: PathConfig, - filePath: string -) => { - const [totalContributors, setTotalContributors] = React.useState< - AvatarItem[] - >([]); - const [loading, setLoading] = React.useState(true); - - const repo = React.useMemo(() => getRepo(pathConfig), [pathConfig]); - - const filterCommitAuthors = (commits: any[]) => { - // const authors: AvatarItem[] = []; - const authorLoginList: string[] = []; - const authors = commits.reduce( - (prev, commit): AvatarItem[] => { - if (!commit.author) { - return prev; - } - - const { - login, - avatar_url, - html_url, - }: { login: string; avatar_url: string; html_url: string } = - commit.author; - - if (login === "ti-chi-bot") { - return prev; - } - - if (!authorLoginList.includes(login)) { - authorLoginList.push(login); - prev.push({ - id: login, - src: avatar_url, - alt: login, - name: commit?.commit?.author?.name || login, - pageUrl: html_url, - }); - } - return prev; - }, - [] - ); - return authors; - }; - - React.useEffect(() => { - async function fetchLatestCommit() { - try { - setLoading(true); - const res = ( - await axios.get(`https://api.github.com/repos/${repo}/commits`, { - params: { - sha: pathConfig.branch, - path: filePath, - }, - }) - ).data; - - const results = filterCommitAuthors(res); - - setTotalContributors(results); - } catch (err) { - // TODO: perform error handling - console.error(`useTotalContributors`, err); - } finally { - setLoading(false); - } - } - pathConfig && filePath && fetchLatestCommit(); - }, [pathConfig, filePath]); - - React.useEffect(() => { - // Create a new node(a sibling node after h1) to render the total contributors - const mdTitleElement = document.querySelector(".markdown-body > h1"); - const contributorNode = document.createElement("div"); - const appendedNode = mdTitleElement?.parentElement?.insertBefore( - contributorNode, - mdTitleElement?.nextSibling - ); - - if (!!totalContributors.length) { - try { - appendedNode && - ReactDOM.render( - , - appendedNode - ); - } catch (error) {} - } - - return () => { - if (!appendedNode) { - return; - } - ReactDOM.unmountComponentAtNode(appendedNode); - // Check if the node is still a child of its parent before removing - if ( - appendedNode.parentNode && - appendedNode.parentNode.contains(appendedNode) - ) { - appendedNode.parentNode.removeChild(appendedNode); - } - }; - }, [totalContributors]); - - return { totalContributors, loading }; -}; diff --git a/src/components/Layout/Navigation/LeftNav.tsx b/src/components/Layout/LeftNav/LeftNav.tsx similarity index 100% rename from src/components/Layout/Navigation/LeftNav.tsx rename to src/components/Layout/LeftNav/LeftNav.tsx diff --git a/src/components/Layout/Navigation/LeftNavTree.tsx b/src/components/Layout/LeftNav/LeftNavTree.tsx similarity index 100% rename from src/components/Layout/Navigation/LeftNavTree.tsx rename to src/components/Layout/LeftNav/LeftNavTree.tsx diff --git a/src/components/Layout/Navigation/RightNav.tsx b/src/components/Layout/RightNav/RightNav.bk.tsx similarity index 100% rename from src/components/Layout/Navigation/RightNav.tsx rename to src/components/Layout/RightNav/RightNav.bk.tsx diff --git a/src/components/Layout/RightNav/RightNav.tsx b/src/components/Layout/RightNav/RightNav.tsx new file mode 100644 index 00000000..e6221a5f --- /dev/null +++ b/src/components/Layout/RightNav/RightNav.tsx @@ -0,0 +1,269 @@ +import * as React from "react"; +import { Trans } from "gatsby-plugin-react-i18next"; +import { useLocation } from "@reach/router"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +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 { transformCustomId, removeHtmlTag } from "shared/utils"; +import { sliceVersionMark } from "shared/utils/anchor"; + +interface RightNavProps { + toc?: TableOfContent[]; + pathConfig: PathConfig; + filePath: string; + buildType?: BuildType; + pageUrl?: string; + bannerVisible?: boolean; +} + +export default function RightNav(props: RightNavProps) { + const { toc = [], bannerVisible } = props; + + const theme = useTheme(); + + 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 ( + <> + + + + + + {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)} + + ); + })} + + ); +}; + +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/TitleAction/TitleAction.tsx b/src/components/Layout/TitleAction/TitleAction.tsx new file mode 100644 index 00000000..b3094403 --- /dev/null +++ b/src/components/Layout/TitleAction/TitleAction.tsx @@ -0,0 +1,242 @@ +import * as React from "react"; +import { useI18next } from "gatsby-plugin-react-i18next"; +import { useStaticQuery, graphql } from "gatsby"; +import copy from "copy-to-clipboard"; + +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 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 { BuildType, PathConfig } from "shared/interface"; +import { calcPDFUrl, getPageType, getRepoFromPathCfg } from "shared/utils"; +import { Tooltip, Divider } from "@mui/material"; + +interface TitleActionProps { + pathConfig: PathConfig; + filePath: string; + pageUrl: string; + buildType: BuildType; + language: string; +} + +export const TitleAction = (props: TitleActionProps) => { + const { pathConfig, filePath, pageUrl, buildType, language } = 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); + + // Get site metadata for feedback URL + const { site } = useStaticQuery(graphql` + query { + site { + siteMetadata { + siteUrl + } + } + } + `); + + const handleContributeClick = (event: React.MouseEvent) => { + setContributeAnchorEl(event.currentTarget); + }; + + const handleContributeClose = () => { + setContributeAnchorEl(null); + }; + + const handleCopyMarkdown = async () => { + if (!pageUrl) return; + + try { + // Fetch markdown content from public path + const markdownUrl = `${pageUrl}.md`; + const response = await fetch(markdownUrl); + if (response.ok) { + const markdownContent = await response.text(); + copy(markdownContent); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + } catch (e) { + console.error("Failed to copy markdown:", e); + } + }; + + const handleViewMarkdown = () => { + if (!pageUrl) return; + window.open(`${pageUrl}.md`, "_blank"); + }; + + const handleDownloadPDF = () => { + if (!pathConfig) return; + const pdfUrl = `https://docs-download.pingcap.com/pdf/${calcPDFUrl( + pathConfig + )}`; + window.open(pdfUrl, "_blank"); + }; + + // Generate feedback URL + const feedbackUrl = React.useMemo(() => { + if (!pathConfig || !filePath) return null; + try { + return `https://github.com/${getRepoFromPathCfg( + pathConfig + )}/issues/new?body=File:%20[/${pathConfig.branch}/${filePath}](${ + site.siteMetadata.siteUrl + }${pageUrl})`; + } catch (e) { + return null; + } + }, [pathConfig, filePath, pageUrl, site.siteMetadata.siteUrl]); + + return ( + + } + > + {/* Contribute Menu */} + {!isArchive && ( + + + + {feedbackUrl && ( + + + {t("doc.feedback")} + + + )} + + + )} + + {/* Copy Markdown for LLM */} + {!isArchive && ( + + + + )} + + {/* View as Markdown */} + {!isArchive && ( + + )} + + {/* Download PDF */} + {pageType === "tidb" && language !== "ja" && ( + + )} + + ); +}; diff --git a/src/components/MDXComponents/H1.module.css b/src/components/MDXComponents/H1.module.css new file mode 100644 index 00000000..fac8326e --- /dev/null +++ b/src/components/MDXComponents/H1.module.css @@ -0,0 +1,3 @@ +.header-actions { + margin-bottom: 32px; +} diff --git a/src/components/MDXComponents/H1.module.css.d.ts b/src/components/MDXComponents/H1.module.css.d.ts new file mode 100644 index 00000000..7d1889c6 --- /dev/null +++ b/src/components/MDXComponents/H1.module.css.d.ts @@ -0,0 +1,2 @@ +// This file is automatically generated. Do not modify this file manually -- YOUR CHANGES WILL BE ERASED! +export const headerActions: string; diff --git a/src/components/MDXComponents/H1.tsx b/src/components/MDXComponents/H1.tsx new file mode 100644 index 00000000..51a343e9 --- /dev/null +++ b/src/components/MDXComponents/H1.tsx @@ -0,0 +1,22 @@ +import { TitleAction } from "components/Layout/TitleAction/TitleAction"; +import { headerActions } from "./H1.module.css"; +import { BuildType, PathConfig } from "shared/interface"; + +export const H1 = (props: { + children: React.ReactNode; + pathConfig: PathConfig; + filePath: string; + pageUrl: string; + buildType: BuildType; + language: string; +}) => { + const { children, ...restProps } = props; + return ( + <> +

{children}

+
+ +
+ + ); +}; diff --git a/src/components/MDXContent.tsx b/src/components/MDXContent.tsx index 615c03ba..87ccfc3e 100644 --- a/src/components/MDXContent.tsx +++ b/src/components/MDXContent.tsx @@ -8,17 +8,12 @@ import Box from "@mui/material/Box"; import * as MDXComponents from "components/MDXComponents"; import { CustomNotice } from "components/Card/CustomNotice"; -import { - PathConfig, - FrontMatter, - BuildType, - CloudPlan, -} from "shared/interface"; -import { useTotalContributors } from "components/Contributors"; +import { PathConfig, BuildType, CloudPlan } from "shared/interface"; import replaceInternalHref from "shared/utils/anchor"; import { Pre } from "components/MDXComponents/Pre"; import { useCustomContent } from "components/MDXComponents/CustomContent"; import { getPageType } from "shared/utils"; +import { H1 } from "./MDXComponents/H1"; export default function MDXContent(props: { data: any; @@ -26,7 +21,6 @@ export default function MDXContent(props: { name: string; pathConfig: PathConfig; filePath: string; - frontmatter: FrontMatter; availIn: string[]; language: string; buildType: BuildType; @@ -39,7 +33,6 @@ export default function MDXContent(props: { name, pathConfig, filePath, - frontmatter, availIn, language, buildType, @@ -62,7 +55,20 @@ export default function MDXContent(props: { ); }); - !frontmatter?.hide_commit && useTotalContributors(pathConfig, filePath); + // Create H1 wrapper with props + const H1WithProps = React.useCallback( + (props: { children: React.ReactNode }) => ( +

+ ), + [pathConfig, filePath, pageUrl] + ); return ( @@ -74,6 +80,7 @@ export default function MDXContent(props: { components={{ ...MDXComponents, pre: Pre, + h1: H1WithProps, CustomContent, }} > diff --git a/src/media/icons/copy.svg b/src/media/icons/copy.svg new file mode 100644 index 00000000..f0135e86 --- /dev/null +++ b/src/media/icons/copy.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/media/icons/edit.svg b/src/media/icons/edit.svg new file mode 100644 index 00000000..3e9c928d --- /dev/null +++ b/src/media/icons/edit.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/media/icons/file.svg b/src/media/icons/file.svg new file mode 100644 index 00000000..f69374b4 --- /dev/null +++ b/src/media/icons/file.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/media/icons/markdown.svg b/src/media/icons/markdown.svg new file mode 100644 index 00000000..3cba8622 --- /dev/null +++ b/src/media/icons/markdown.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/styles/docTemplate.css b/src/styles/docTemplate.css index 1bbc345d..806d57da 100644 --- a/src/styles/docTemplate.css +++ b/src/styles/docTemplate.css @@ -144,6 +144,7 @@ .markdown-body { h1 { font-weight: 400; + border-bottom: 0; } h2, diff --git a/src/templates/DocTemplate.tsx b/src/templates/DocTemplate.tsx index 7fcfccaa..a0b85b38 100644 --- a/src/templates/DocTemplate.tsx +++ b/src/templates/DocTemplate.tsx @@ -11,11 +11,9 @@ import Layout from "components/Layout"; import { LeftNavDesktop, LeftNavMobile, -} from "components/Layout/Navigation/LeftNav"; +} from "components/Layout/LeftNav/LeftNav"; import MDXContent from "components/MDXContent"; -import RightNav, { - RightNavMobile, -} from "components/Layout/Navigation/RightNav"; +import RightNav, { RightNavMobile } from "components/Layout/RightNav/RightNav"; import { TableOfContent, PageContext, @@ -288,7 +286,6 @@ function DocTemplate({ name={name} pathConfig={pathConfig} filePath={filePath} - frontmatter={frontmatter} availIn={availIn.version} language={language} buildType={buildType} diff --git a/src/theme/index.tsx b/src/theme/index.tsx index e8151258..df34343b 100644 --- a/src/theme/index.tsx +++ b/src/theme/index.tsx @@ -175,10 +175,10 @@ theme = createTheme(theme, { 100: "#FBFDFD", 200: "#F5F8FA", 300: "#EDF0F1", - 400: "#E3E8EA", + 400: "#ECE3E5", 500: "#C8CED0", - 600: "#9FAAAD", - 700: "#6C7679", + 600: "#9FA9AD", + 700: "#6F787B", 800: "#3D4143", 900: "#262A2C", }, From e39608cee3f5e7cb23608959ea6cf5536b643797 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Thu, 18 Dec 2025 17:27:42 +0800 Subject: [PATCH 02/37] chore: update subproject commit reference in docs --- docs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs b/docs index 0d2c6cf3..631b8250 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 0d2c6cf365673ac810cda415604458bf713b9faa +Subproject commit 631b8250ef16f47f73c2387221b9d8a18107f7f1 From 0254666ad1632d83f7495474940823fba28c4c17 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Thu, 18 Dec 2025 17:47:27 +0800 Subject: [PATCH 03/37] feat: add "Improve this document" link in TitleAction component - Introduced a new feature that generates a GitHub edit link for the current document, allowing users to easily contribute improvements. - The link is conditionally rendered based on the presence of pathConfig and filePath, enhancing user engagement with documentation. --- .../Layout/TitleAction/TitleAction.tsx | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/components/Layout/TitleAction/TitleAction.tsx b/src/components/Layout/TitleAction/TitleAction.tsx index b3094403..e26b2dd2 100644 --- a/src/components/Layout/TitleAction/TitleAction.tsx +++ b/src/components/Layout/TitleAction/TitleAction.tsx @@ -108,6 +108,13 @@ export const TitleAction = (props: TitleActionProps) => { } }, [pathConfig, filePath, pageUrl, site.siteMetadata.siteUrl]); + const improveUrl = React.useMemo(() => { + if (!pathConfig || !filePath) return null; + return `https://github.com/${getRepoFromPathCfg(pathConfig)}/edit/${ + pathConfig.branch + }/${filePath}`; + }, [pathConfig.branch, filePath]); + return ( { )} + {improveUrl && ( + + + {t("doc.improve")} + + + )} )} From 468fbf050c0928f77bdf7355c5b229ceecbd850c Mon Sep 17 00:00:00 2001 From: Suhaha Date: Thu, 18 Dec 2025 17:49:45 +0800 Subject: [PATCH 04/37] fix: update feedback translation for clarity in English and Japanese - Changed the feedback string from "Request docs changes" to "Report a doc issue" in both English and Japanese translation files for improved clarity and user understanding. --- locale/en/translation.json | 2 +- locale/ja/translation.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/locale/en/translation.json b/locale/en/translation.json index 7a8afee4..43c8601e 100644 --- a/locale/en/translation.json +++ b/locale/en/translation.json @@ -43,7 +43,7 @@ "toc": "ON THIS PAGE", "download-pdf": "Download PDF", "improve": "Edit this page", - "feedback": "Request docs changes", + "feedback": "Report a doc issue", "feedbackAskTug": "Ask the community", "latestCommit": "was last updated", "deprecation": { diff --git a/locale/ja/translation.json b/locale/ja/translation.json index bd181e96..10d7c0c2 100644 --- a/locale/ja/translation.json +++ b/locale/ja/translation.json @@ -40,7 +40,7 @@ "toc": "このページの内容", "download-pdf": "PDFをダウンロード", "improve": "このページを編集", - "feedback": "ドキュメントの変更をリクエストする", + "feedback": "ドキュメントの問題を報告する", "latestCommit": "最終更新日", "deprecation": { "tidb": { From af715b7ae7e5042639398add0ae5135e51100c06 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Fri, 19 Dec 2025 14:10:37 +0800 Subject: [PATCH 05/37] fix: update TitleAction component for improved layout and button text - Added flexWrap property to the TitleAction component to enhance layout responsiveness. - Changed button text from "Copy Markdown for LLM" to "Copy for LLM" for brevity and clarity. --- src/components/Layout/TitleAction/TitleAction.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Layout/TitleAction/TitleAction.tsx b/src/components/Layout/TitleAction/TitleAction.tsx index e26b2dd2..5eb283e5 100644 --- a/src/components/Layout/TitleAction/TitleAction.tsx +++ b/src/components/Layout/TitleAction/TitleAction.tsx @@ -121,6 +121,7 @@ export const TitleAction = (props: TitleActionProps) => { spacing={3} alignItems="center" justifyItems="center" + flexWrap="wrap" divider={ { color: theme.palette.carbon[700], }} > - Copy Markdown for LLM + Copy for LLM )} From 6cb99b0abdfb975cffd42a4288b34e49a94c7799 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Mon, 22 Dec 2025 16:55:10 +0800 Subject: [PATCH 06/37] fix: update color in RightNav component for better visibility - Changed the text color in the RightNav component from carbon[600] to carbon[700] to enhance readability and visual contrast. --- src/components/Layout/RightNav/RightNav.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Layout/RightNav/RightNav.tsx b/src/components/Layout/RightNav/RightNav.tsx index e6221a5f..cec2fddf 100644 --- a/src/components/Layout/RightNav/RightNav.tsx +++ b/src/components/Layout/RightNav/RightNav.tsx @@ -113,7 +113,7 @@ export default function RightNav(props: RightNavProps) { component="div" sx={{ paddingLeft: "0.5rem", - color: theme.palette.carbon[600], + color: theme.palette.carbon[700], fontSize: "0.875rem", fontWeight: "500", lineHeight: "1.25rem", From 5f229c92ff2bab2b7f037231a4d0d55623c48613 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Wed, 7 Jan 2026 14:53:13 +0800 Subject: [PATCH 07/37] fix: conditionally render TOC in RightNav component - Updated the RightNav component to only display the Table of Contents (TOC) when it contains items, improving layout and preventing unnecessary empty space. --- src/components/Layout/RightNav/RightNav.tsx | 34 +++++++++++---------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/components/Layout/RightNav/RightNav.tsx b/src/components/Layout/RightNav/RightNav.tsx index cec2fddf..f8481014 100644 --- a/src/components/Layout/RightNav/RightNav.tsx +++ b/src/components/Layout/RightNav/RightNav.tsx @@ -108,22 +108,24 @@ export default function RightNav(props: RightNavProps) { gap: "36px", }} > - - - - - {generateToc(toc, 0, activeId)} - + {toc.length > 0 && ( + + + + + {generateToc(toc, 0, activeId)} + + )} ); From 8c66aff7a1bfe9a1bebf35309f063c71b69227af Mon Sep 17 00:00:00 2001 From: Suhaha Date: Fri, 9 Jan 2026 17:58:01 +0800 Subject: [PATCH 08/37] feat: enhance header layout and functionality - Introduced a new header height management system with constants for different header states, improving layout consistency across components. - Updated the Header, LeftNav, RightNav, and various templates to utilize the new header height logic, ensuring proper spacing based on banner visibility. - Added a language switcher option in the HeaderAction component, allowing for better localization support. - Adjusted styles in typography to align with the new header heights, enhancing overall visual coherence. --- package.json | 1 + src/components/Layout/Banner/Banner.tsx | 5 +- src/components/Layout/Header.tsx | 78 ++++++++++++++------- src/components/Layout/HeaderAction.tsx | 13 +++- src/components/Layout/LeftNav/LeftNav.tsx | 5 +- src/components/Layout/RightNav/RightNav.tsx | 7 +- src/media/logo/tidb-logo-withtext.svg | 24 +++---- src/shared/headerHeight.ts | 25 +++++++ src/styles/typographyDefine.css | 4 +- src/templates/404Template.tsx | 3 +- src/templates/CloudAPIReferenceTemplate.tsx | 3 +- src/templates/DocSearchTemplate.tsx | 3 +- src/templates/DocTemplate.tsx | 6 +- 13 files changed, 123 insertions(+), 54 deletions(-) create mode 100644 src/shared/headerHeight.ts diff --git a/package.json b/package.json index 9481a66f..ae5d972d 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "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", diff --git a/src/components/Layout/Banner/Banner.tsx b/src/components/Layout/Banner/Banner.tsx index 75024dec..f5929772 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({ @@ -53,39 +54,68 @@ export default function Header(props: HeaderProps) { height: "100%", paddingLeft: "24px", paddingRight: "24px", + flexDirection: "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 && ( + + + + )} + ); diff --git a/src/components/Layout/HeaderAction.tsx b/src/components/Layout/HeaderAction.tsx index d14c675e..eae68963 100644 --- a/src/components/Layout/HeaderAction.tsx +++ b/src/components/Layout/HeaderAction.tsx @@ -62,8 +62,15 @@ export default function HeaderAction(props: { docInfo?: { type: string; version: string }; buildType?: BuildType; pageUrl?: string; + showLangSwitch?: boolean; }) { - const { supportedLocales, docInfo, buildType, pageUrl } = props; + const { + supportedLocales, + docInfo, + buildType, + pageUrl, + showLangSwitch = true, + } = props; const { language, t } = useI18next(); const { showTiDBAIButton, initializingTiDBAI } = useTiDBAIStatus(); const isAutoTranslation = useIsAutoTranslation(pageUrl || ""); @@ -77,7 +84,7 @@ export default function HeaderAction(props: { }} sx={{ marginLeft: "auto", alignItems: "center" }} > - {supportedLocales.length > 0 && ( + {showLangSwitch && supportedLocales.length > 0 && ( )} {docInfo && !isAutoTranslation && buildType !== "archive" && ( @@ -117,7 +124,7 @@ const LANG_MAP = { [Locale.ja]: "日本語", }; -const LangSwitch = (props: { +export const LangSwitch = (props: { language?: string; changeLanguage?: () => void; supportedLocales: Locale[]; diff --git a/src/components/Layout/LeftNav/LeftNav.tsx b/src/components/Layout/LeftNav/LeftNav.tsx index 1bd37292..9d6f4660 100644 --- a/src/components/Layout/LeftNav/LeftNav.tsx +++ b/src/components/Layout/LeftNav/LeftNav.tsx @@ -14,6 +14,7 @@ import LeftNavTree 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"; @@ -55,9 +56,9 @@ export function LeftNavDesktop(props: LeftNavProps) { - - - - + + + + + - - - - - - + + + + + + - + diff --git a/src/shared/headerHeight.ts b/src/shared/headerHeight.ts new file mode 100644 index 00000000..facec560 --- /dev/null +++ b/src/shared/headerHeight.ts @@ -0,0 +1,25 @@ +/** + * Header height constants + * These values should be kept in sync with the actual header height in Header.tsx + */ +export const HEADER_HEIGHT = { + /** Banner height: 40px */ + BANNER: "40px", + /** First row height (Logo row): 72px */ + FIRST_ROW: "72px", + /** Second row height (Navigation row): 56px */ + SECOND_ROW: "56px", + /** Header height without banner: 128px (72px + 56px) */ + WITHOUT_BANNER: "128px", + /** Header height with banner: 168px (40px + 72px + 56px) */ + WITH_BANNER: "168px", +} as const; + +/** + * Get header height based on banner visibility + */ +export const getHeaderHeight = (bannerEnabled: boolean): string => { + return bannerEnabled + ? HEADER_HEIGHT.WITH_BANNER + : HEADER_HEIGHT.WITHOUT_BANNER; +}; diff --git a/src/styles/typographyDefine.css b/src/styles/typographyDefine.css index a6d93b35..9f441d35 100644 --- a/src/styles/typographyDefine.css +++ b/src/styles/typographyDefine.css @@ -72,7 +72,7 @@ h3, h4, h5, h6 { - scroll-margin-top: 5rem; + scroll-margin-top: 128px; } .doc-feature-banner h1, @@ -81,5 +81,5 @@ h6 { .doc-feature-banner h4, .doc-feature-banner h5, .doc-feature-banner h6 { - scroll-margin-top: 7.5rem; + scroll-margin-top: 168px; } diff --git a/src/templates/404Template.tsx b/src/templates/404Template.tsx index c7ebb8c9..60bf52cd 100644 --- a/src/templates/404Template.tsx +++ b/src/templates/404Template.tsx @@ -12,6 +12,7 @@ import Layout from "components/Layout"; import { BuildType, Locale, Repo } from "shared/interface"; import { Page404Icon } from "components/Icons"; import Seo from "components/Seo"; +import { getHeaderHeight } from "shared/headerHeight"; import CONFIG from "../../docs/docs.json"; import { useEffect, useRef, useState } from "react"; @@ -123,7 +124,7 @@ export default function PageNotFoundTemplate({ diff --git a/src/templates/DocSearchTemplate.tsx b/src/templates/DocSearchTemplate.tsx index a07c8843..cfcbe7f1 100644 --- a/src/templates/DocSearchTemplate.tsx +++ b/src/templates/DocSearchTemplate.tsx @@ -28,6 +28,7 @@ import { TIDB_ZH_SEARCH_INDEX_VERSION, } from "shared/resources"; import { Locale } from "shared/interface"; +import { getHeaderHeight } from "shared/headerHeight"; import { FeedbackSurveyCampaign } from "components/Campaign/FeedbackSurvey"; import { useEffect } from "react"; @@ -203,7 +204,7 @@ export default function DocSearchTemplate({ Date: Sat, 10 Jan 2026 19:17:40 +0800 Subject: [PATCH 09/37] feat: add new Header components for enhanced navigation and functionality - Introduced HeaderAction, HeaderNav, and LangSwitch components to improve the header layout and user experience. - Implemented a TiDB AI button in HeaderAction for enhanced interactivity. - Added mobile and desktop navigation stacks in HeaderNav for better accessibility. - Integrated a language switcher in LangSwitch to support multiple locales. - Updated the theme with a color adjustment for improved visual consistency. --- .../Layout/{ => Header}/HeaderAction.tsx | 143 +----------------- .../Layout/{ => Header}/HeaderNav.tsx | 0 src/components/Layout/Header/LangSwitch.tsx | 138 +++++++++++++++++ .../Layout/{Header.tsx => Header/index.tsx} | 8 +- src/theme/index.tsx | 2 +- 5 files changed, 146 insertions(+), 145 deletions(-) rename src/components/Layout/{ => Header}/HeaderAction.tsx (59%) rename src/components/Layout/{ => Header}/HeaderNav.tsx (100%) create mode 100644 src/components/Layout/Header/LangSwitch.tsx rename src/components/Layout/{Header.tsx => Header/index.tsx} (96%) diff --git a/src/components/Layout/HeaderAction.tsx b/src/components/Layout/Header/HeaderAction.tsx similarity index 59% rename from src/components/Layout/HeaderAction.tsx rename to src/components/Layout/Header/HeaderAction.tsx index eae68963..3e913b93 100644 --- a/src/components/Layout/HeaderAction.tsx +++ b/src/components/Layout/Header/HeaderAction.tsx @@ -1,19 +1,15 @@ import * as React from "react"; -import { Trans, useI18next } from "gatsby-plugin-react-i18next"; +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 Button from "@mui/material/Button"; import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import IconButton from "@mui/material/IconButton"; +import Typography from "@mui/material/Typography"; import StarIcon from "media/icons/star.svg"; -import 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"; @@ -62,15 +58,8 @@ export default function HeaderAction(props: { docInfo?: { type: string; version: string }; buildType?: BuildType; pageUrl?: string; - showLangSwitch?: boolean; }) { - const { - supportedLocales, - docInfo, - buildType, - pageUrl, - showLangSwitch = true, - } = props; + const { docInfo, buildType, pageUrl } = props; const { language, t } = useI18next(); const { showTiDBAIButton, initializingTiDBAI } = useTiDBAIStatus(); const isAutoTranslation = useIsAutoTranslation(pageUrl || ""); @@ -84,9 +73,6 @@ export default function HeaderAction(props: { }} sx={{ marginLeft: "auto", alignItems: "center" }} > - {showLangSwitch && supportedLocales.length > 0 && ( - - )} {docInfo && !isAutoTranslation && buildType !== "archive" && ( <> @@ -118,129 +104,6 @@ export default function HeaderAction(props: { ); } -const LANG_MAP = { - [Locale.en]: "EN", - [Locale.zh]: "中文", - [Locale.ja]: "日本語", -}; - -export const LangSwitch = (props: { - language?: string; - changeLanguage?: () => void; - supportedLocales: Locale[]; -}) => { - const { supportedLocales } = props; - - const [anchorEl, setAnchorEl] = React.useState(null); - - const theme = useTheme(); - const { language, changeLanguage } = useI18next(); - - const open = Boolean(anchorEl); - const handleClick = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; - const handleClose = () => { - setAnchorEl(null); - }; - - const toggleLanguage = (locale: Locale) => () => { - changeLanguage(locale); - handleClose(); - }; - - return ( - - - - - } - endIcon={ - - } - sx={{ - display: { - xs: "none", - lg: "inline-flex", - }, - }} - > - {LANG_MAP[language as Locale]} - - - - - - - - - - - - - - - - - - - - ); -}; - const TiDBCloudBtnGroup = () => { const [anchorEl, setAnchorEl] = React.useState(null); const open = Boolean(anchorEl); diff --git a/src/components/Layout/HeaderNav.tsx b/src/components/Layout/Header/HeaderNav.tsx similarity index 100% rename from src/components/Layout/HeaderNav.tsx rename to src/components/Layout/Header/HeaderNav.tsx diff --git a/src/components/Layout/Header/LangSwitch.tsx b/src/components/Layout/Header/LangSwitch.tsx new file mode 100644 index 00000000..09ca57ba --- /dev/null +++ b/src/components/Layout/Header/LangSwitch.tsx @@ -0,0 +1,138 @@ +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 KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import TranslateIcon from "media/icons/globe-02.svg"; + +import { Locale } from "shared/interface"; + +const LANG_MAP = { + [Locale.en]: "EN", + [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 96% rename from src/components/Layout/Header.tsx rename to src/components/Layout/Header/index.tsx index afa41905..d7ea69b1 100644 --- a/src/components/Layout/Header.tsx +++ b/src/components/Layout/Header/index.tsx @@ -7,14 +7,15 @@ import { useTheme } from "@mui/material/styles"; import LinkComponent, { BlueAnchorLink } from "components/Link"; import HeaderNavStack, { HeaderNavStackMobile, -} from "components/Layout/HeaderNav"; -import HeaderAction, { LangSwitch } from "components/Layout/HeaderAction"; +} from "components/Layout/Header/HeaderNav"; +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 { 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"; @@ -94,7 +95,6 @@ export default function Header(props: HeaderProps) { docInfo={props.docInfo} buildType={props.buildType} pageUrl={props.pageUrl} - showLangSwitch={false} /> diff --git a/src/theme/index.tsx b/src/theme/index.tsx index df34343b..a685cd83 100644 --- a/src/theme/index.tsx +++ b/src/theme/index.tsx @@ -175,7 +175,7 @@ theme = createTheme(theme, { 100: "#FBFDFD", 200: "#F5F8FA", 300: "#EDF0F1", - 400: "#ECE3E5", + 400: "#DCE3E5", 500: "#C8CED0", 600: "#9FA9AD", 700: "#6F787B", From a662b03558aceab3aeee42ae0c89dbfd9b90efd9 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Sat, 10 Jan 2026 19:48:33 +0800 Subject: [PATCH 10/37] refactor: enhance header components and introduce mobile navigation - Simplified HeaderAction by consolidating search and action button elements for improved layout. - Updated HeaderNav to remove unused imports and commented-out code, streamlining the component. - Added new HeaderNavMobile component for better mobile navigation experience. - Adjusted spacing and styles in various components for consistency and improved usability. - Enhanced LangSwitch with clearer language labels and updated button styles. - Increased search input width and height for better accessibility. --- src/components/Layout/Header/HeaderAction.tsx | 62 ++--- src/components/Layout/Header/HeaderNav.tsx | 230 +----------------- .../Layout/Header/HeaderNavMobile.tsx | 221 +++++++++++++++++ src/components/Layout/Header/LangSwitch.tsx | 7 +- src/components/Layout/Header/index.tsx | 5 +- src/components/Search/index.tsx | 6 +- 6 files changed, 271 insertions(+), 260 deletions(-) create mode 100644 src/components/Layout/Header/HeaderNavMobile.tsx diff --git a/src/components/Layout/Header/HeaderAction.tsx b/src/components/Layout/Header/HeaderAction.tsx index 3e913b93..a22e76dd 100644 --- a/src/components/Layout/Header/HeaderAction.tsx +++ b/src/components/Layout/Header/HeaderAction.tsx @@ -67,36 +67,33 @@ export default function HeaderAction(props: { return ( {docInfo && !isAutoTranslation && buildType !== "archive" && ( <> - - - {language === "en" && showTiDBAIButton && ( - } - disabled={initializingTiDBAI} - sx={{ - display: { - xs: "none", - xl: "flex", - }, - }} - onClick={() => { - window.tidbai.open = true; - }} - > - Ask AI - - )} - + + {language === "en" && showTiDBAIButton && ( + } + disabled={initializingTiDBAI} + size="large" + sx={{ + fontSize: "14px", + display: { + xs: "none", + xl: "flex", + }, + }} + onClick={() => { + window.tidbai.open = true; + }} + > + Ask TiDB.ai + + )} )} {language === "en" && } @@ -118,18 +115,22 @@ const TiDBCloudBtnGroup = () => { <> @@ -139,11 +140,16 @@ const TiDBCloudBtnGroup = () => { href="https://tidbcloud.com/free-trial" // https://developer.chrome.com/blog/referrer-policy-new-chrome-default/ referrerPolicy="no-referrer-when-downgrade" + size="large" + sx={{ + fontSize: "14px", + }} > Start for Free + {/* Mobile menu */} - {["zh"].includes(language) && ( + {/* {["zh"].includes(language) && ( )} @@ -89,21 +75,16 @@ export default function HeaderNavStack(props: { label={t("navbar.learningCenter")} to={generateLearningCenterURL(language)} /> - )} - - + )} */} - {language === "zh" && ( + {/* {language === "zh" && ( } to={generateDownloadURL(language)} alt="download" startIcon={} /> - )} + )} */} ); } @@ -160,202 +141,3 @@ const NavItem = (props: { ); }; - -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/Header/HeaderNavMobile.tsx b/src/components/Layout/Header/HeaderNavMobile.tsx new file mode 100644 index 00000000..58162367 --- /dev/null +++ b/src/components/Layout/Header/HeaderNavMobile.tsx @@ -0,0 +1,221 @@ +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 { 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 LinkComponent from "components/Link"; +import { + // generateDownloadURL, + // generateLearningCenterURL, + 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 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"), + }) + } + > + + + + + + )} */} + + + ); +} diff --git a/src/components/Layout/Header/LangSwitch.tsx b/src/components/Layout/Header/LangSwitch.tsx index 09ca57ba..e6fa46ca 100644 --- a/src/components/Layout/Header/LangSwitch.tsx +++ b/src/components/Layout/Header/LangSwitch.tsx @@ -15,8 +15,8 @@ import TranslateIcon from "media/icons/globe-02.svg"; import { Locale } from "shared/interface"; const LANG_MAP = { - [Locale.en]: "EN", - [Locale.zh]: "中文", + [Locale.en]: "English", + [Locale.zh]: "简体中文", [Locale.ja]: "日本語", }; @@ -72,7 +72,10 @@ export const LangSwitch = (props: { sx={{ fill: theme.palette.carbon[900], marginLeft: "-4px" }} /> } + size="large" sx={{ + fontSize: "14px", + paddingLeft: "16px", display: { xs: "none", lg: "inline-flex", diff --git a/src/components/Layout/Header/index.tsx b/src/components/Layout/Header/index.tsx index d7ea69b1..b5b82108 100644 --- a/src/components/Layout/Header/index.tsx +++ b/src/components/Layout/Header/index.tsx @@ -5,9 +5,8 @@ import Toolbar from "@mui/material/Toolbar"; import { useTheme } from "@mui/material/styles"; import LinkComponent, { BlueAnchorLink } from "components/Link"; -import HeaderNavStack, { - HeaderNavStackMobile, -} from "components/Layout/Header/HeaderNav"; +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"; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index b5afefe8..78d51cdf 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -18,8 +18,8 @@ const StyledTextField = styled((props: TextFieldProps) => ( "& .MuiOutlinedInput-root": { paddingLeft: "12px", background: theme.palette.background.paper, - height: "32px", - fontSize: "14px", + height: "40px", + fontSize: "16px", "&:hover fieldset": { borderColor: theme.palette.text.secondary, borderWidth: "1px", @@ -31,7 +31,7 @@ const StyledTextField = styled((props: TextFieldProps) => ( }, })); -const SEARCH_WIDTH = 250; +const SEARCH_WIDTH = 400; enum SearchType { Onsite = "onsite", From 8f3de84457482ffa5e9d7bff5a91b78b347a4fc7 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Sat, 10 Jan 2026 19:56:33 +0800 Subject: [PATCH 11/37] refactor: reorganize getPageType utility for improved structure - Moved getPageType and PageType type definitions from shared/utils to shared/getPageType for better modularity. - Updated imports across various components to reflect the new location of getPageType, ensuring consistent usage throughout the codebase. - Cleaned up unused imports in several components, enhancing overall code clarity and maintainability. --- src/components/Layout/Header/HeaderNav.tsx | 2 +- .../Layout/Header/HeaderNavMobile.tsx | 7 +----- .../Layout/RightNav/RightNav.bk.tsx | 2 +- .../Layout/TitleAction/TitleAction.tsx | 3 ++- .../MDXComponents/CustomContent.tsx | 2 +- src/components/MDXContent.tsx | 2 +- src/shared/filterRightToc.ts | 2 +- src/shared/getPageType.ts | 16 ++++++++++++++ src/shared/useIsAutoTranslation.ts | 2 +- src/shared/utils/index.ts | 22 +++++-------------- src/templates/DocTemplate.tsx | 3 ++- 11 files changed, 32 insertions(+), 31 deletions(-) create mode 100644 src/shared/getPageType.ts diff --git a/src/components/Layout/Header/HeaderNav.tsx b/src/components/Layout/Header/HeaderNav.tsx index 74cb010c..31f95dd3 100644 --- a/src/components/Layout/Header/HeaderNav.tsx +++ b/src/components/Layout/Header/HeaderNav.tsx @@ -6,7 +6,7 @@ import Stack from "@mui/material/Stack"; import { useTheme } from "@mui/material/styles"; import LinkComponent from "components/Link"; -import { getPageType, PageType } from "shared/utils"; +import { getPageType, PageType } from "shared/getPageType"; import { BuildType } from "shared/interface"; import { GTMEvent, gtmTrack } from "shared/utils/gtm"; import { CLOUD_MODE_KEY, useCloudPlan } from "shared/useCloudPlan"; diff --git a/src/components/Layout/Header/HeaderNavMobile.tsx b/src/components/Layout/Header/HeaderNavMobile.tsx index 58162367..6907f271 100644 --- a/src/components/Layout/Header/HeaderNavMobile.tsx +++ b/src/components/Layout/Header/HeaderNavMobile.tsx @@ -9,12 +9,7 @@ import MenuItem from "@mui/material/MenuItem"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import LinkComponent from "components/Link"; -import { - // generateDownloadURL, - // generateLearningCenterURL, - getPageType, - PageType, -} from "shared/utils"; +import { getPageType, PageType } from "shared/getPageType"; import { BuildType } from "shared/interface"; import { GTMEvent, gtmTrack } from "shared/utils/gtm"; diff --git a/src/components/Layout/RightNav/RightNav.bk.tsx b/src/components/Layout/RightNav/RightNav.bk.tsx index 91d8294a..293d7cdc 100644 --- a/src/components/Layout/RightNav/RightNav.bk.tsx +++ b/src/components/Layout/RightNav/RightNav.bk.tsx @@ -24,7 +24,7 @@ import { removeHtmlTag, } from "shared/utils"; import { sliceVersionMark } from "shared/utils/anchor"; -import { getPageType } from "shared/utils"; +import { getPageType } from "shared/getPageType"; interface RightNavProps { toc?: TableOfContent[]; diff --git a/src/components/Layout/TitleAction/TitleAction.tsx b/src/components/Layout/TitleAction/TitleAction.tsx index 8001d49f..481fd11f 100644 --- a/src/components/Layout/TitleAction/TitleAction.tsx +++ b/src/components/Layout/TitleAction/TitleAction.tsx @@ -18,7 +18,8 @@ import MarkdownIcon from "media/icons/markdown.svg"; import FileIcon from "media/icons/file.svg"; import { BuildType, PathConfig } from "shared/interface"; -import { calcPDFUrl, getPageType, getRepoFromPathCfg } from "shared/utils"; +import { calcPDFUrl, getRepoFromPathCfg } from "shared/utils"; +import { getPageType } from "shared/getPageType"; import { Tooltip, Divider } from "@mui/material"; interface TitleActionProps { diff --git a/src/components/MDXComponents/CustomContent.tsx b/src/components/MDXComponents/CustomContent.tsx index aefe6c8c..d3547220 100644 --- a/src/components/MDXComponents/CustomContent.tsx +++ b/src/components/MDXComponents/CustomContent.tsx @@ -1,5 +1,5 @@ import { PropsWithChildren } from "react"; -import { PageType } from "shared/utils"; +import { PageType } from "shared/getPageType"; import { CloudPlan } from "shared/interface"; interface CustomContentProps { diff --git a/src/components/MDXContent.tsx b/src/components/MDXContent.tsx index 87ccfc3e..64a84b21 100644 --- a/src/components/MDXContent.tsx +++ b/src/components/MDXContent.tsx @@ -12,7 +12,7 @@ import { PathConfig, BuildType, CloudPlan } from "shared/interface"; import replaceInternalHref from "shared/utils/anchor"; import { Pre } from "components/MDXComponents/Pre"; import { useCustomContent } from "components/MDXComponents/CustomContent"; -import { getPageType } from "shared/utils"; +import { getPageType } from "shared/getPageType"; import { H1 } from "./MDXComponents/H1"; export default function MDXContent(props: { diff --git a/src/shared/filterRightToc.ts b/src/shared/filterRightToc.ts index 0b1e5754..bc77c58b 100644 --- a/src/shared/filterRightToc.ts +++ b/src/shared/filterRightToc.ts @@ -1,5 +1,5 @@ import { TableOfContent, CloudPlan } from "./interface"; -import { PageType } from "./utils"; +import { PageType } from "./getPageType"; /** * Filter right TOC based on CustomContent conditions diff --git a/src/shared/getPageType.ts b/src/shared/getPageType.ts new file mode 100644 index 00000000..e9011bb7 --- /dev/null +++ b/src/shared/getPageType.ts @@ -0,0 +1,16 @@ +export type PageType = "home" | "tidb" | "tidbcloud" | undefined; + +export const getPageType = (language?: string, pageUrl?: string): PageType => { + if (pageUrl === "/" || pageUrl === `/${language}/`) { + return "home"; + } else if (pageUrl?.includes("/tidb/")) { + return "tidb"; + } else if ( + pageUrl?.includes("/tidbcloud/") || + pageUrl?.endsWith("/tidbcloud") + ) { + return "tidbcloud"; + } + return; +}; + diff --git a/src/shared/useIsAutoTranslation.ts b/src/shared/useIsAutoTranslation.ts index 1756735b..7eb691d1 100644 --- a/src/shared/useIsAutoTranslation.ts +++ b/src/shared/useIsAutoTranslation.ts @@ -1,5 +1,5 @@ import { useI18next } from "gatsby-plugin-react-i18next"; -import { getPageType } from "./utils"; +import { getPageType } from "./getPageType"; export const useIsAutoTranslation = (pageUrl: string) => { const { language } = useI18next(); diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts index 3bb0edf7..65e412a0 100644 --- a/src/shared/utils/index.ts +++ b/src/shared/utils/index.ts @@ -215,7 +215,11 @@ export const AllVersion = Object.keys(CONFIG.docs).reduce((acc, val) => { return acc; }, {} as Record>); -export function convertVersionName(version: string, stable: string, repo?: string) { +export function convertVersionName( + version: string, + stable: string, + repo?: string +) { const devBranch = repo === "tidb-in-kubernetes" ? "main" : "master"; switch (version) { case devBranch: @@ -293,19 +297,3 @@ export function scrollToElementIfInView( ) { element.scrollIntoView({ block: "end" }); } - -export type PageType = "home" | "tidb" | "tidbcloud" | undefined; - -export const getPageType = (language?: string, pageUrl?: string): PageType => { - if (pageUrl === "/" || pageUrl === `/${language}/`) { - return "home"; - } else if (pageUrl?.includes("/tidb/")) { - return "tidb"; - } else if ( - pageUrl?.includes("/tidbcloud/") || - pageUrl?.endsWith("/tidbcloud") - ) { - return "tidbcloud"; - } - return; -}; diff --git a/src/templates/DocTemplate.tsx b/src/templates/DocTemplate.tsx index f828a85b..ca27db35 100644 --- a/src/templates/DocTemplate.tsx +++ b/src/templates/DocTemplate.tsx @@ -24,7 +24,8 @@ import { CloudPlan, } from "shared/interface"; import Seo from "components/Seo"; -import { getStable, generateUrl, getPageType } from "shared/utils"; +import { getStable, generateUrl } from "shared/utils"; +import { getPageType } from "shared/getPageType"; import GitCommitInfoCard from "components/Card/GitCommitInfoCard"; import { FeedbackSection } from "components/Card/FeedbackSection"; import { FeedbackSurveyCampaign } from "components/Campaign/FeedbackSurvey"; From ed551668825f2db53840fdf299538bc679581899 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Sat, 10 Jan 2026 20:15:07 +0800 Subject: [PATCH 12/37] refactor: replace getPageType with usePageType for improved modularity - Replaced instances of getPageType with the new usePageType hook across various components to enhance code consistency and maintainability. - Updated imports to reflect the new structure, ensuring all components utilize the updated page type logic. - Removed the deprecated getPageType utility, streamlining the codebase and improving clarity. --- src/components/Layout/Header/HeaderNav.tsx | 12 ++-- .../Layout/Header/HeaderNavMobile.tsx | 14 ++--- .../Layout/RightNav/RightNav.bk.tsx | 4 +- .../Layout/TitleAction/TitleAction.tsx | 6 +- .../MDXComponents/CustomContent.tsx | 17 +++++- src/components/MDXContent.tsx | 4 +- src/shared/filterRightToc.ts | 2 +- src/shared/getPageType.ts | 16 ----- src/shared/useIsAutoTranslation.ts | 9 +-- src/shared/usePageType.ts | 58 +++++++++++++++++++ src/templates/DocTemplate.tsx | 4 +- 11 files changed, 100 insertions(+), 46 deletions(-) delete mode 100644 src/shared/getPageType.ts create mode 100644 src/shared/usePageType.ts diff --git a/src/components/Layout/Header/HeaderNav.tsx b/src/components/Layout/Header/HeaderNav.tsx index 31f95dd3..570b66df 100644 --- a/src/components/Layout/Header/HeaderNav.tsx +++ b/src/components/Layout/Header/HeaderNav.tsx @@ -6,7 +6,7 @@ import Stack from "@mui/material/Stack"; import { useTheme } from "@mui/material/styles"; import LinkComponent from "components/Link"; -import { getPageType, PageType } from "shared/getPageType"; +import { usePageType, PageType } from "shared/usePageType"; import { BuildType } from "shared/interface"; import { GTMEvent, gtmTrack } from "shared/utils/gtm"; import { CLOUD_MODE_KEY, useCloudPlan } from "shared/useCloudPlan"; @@ -15,13 +15,13 @@ import { CLOUD_MODE_KEY, useCloudPlan } from "shared/useCloudPlan"; // 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" + const [selectedItem, setSelectedItem] = React.useState(() => + usePageType(language, pageUrl) ); // update in client side React.useEffect(() => { - setSelectedItem(getPageType(language, window.location.pathname)); + setSelectedItem(usePageType(language, window.location.pathname)); }, [language]); return selectedItem; @@ -50,7 +50,7 @@ export default function HeaderNavStack(props: { > {props.buildType !== "archive" && ( diff --git a/src/components/Layout/Header/HeaderNavMobile.tsx b/src/components/Layout/Header/HeaderNavMobile.tsx index 6907f271..ed81604f 100644 --- a/src/components/Layout/Header/HeaderNavMobile.tsx +++ b/src/components/Layout/Header/HeaderNavMobile.tsx @@ -9,7 +9,7 @@ import MenuItem from "@mui/material/MenuItem"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import LinkComponent from "components/Link"; -import { getPageType, PageType } from "shared/getPageType"; +import { usePageType, PageType } from "shared/usePageType"; import { BuildType } from "shared/interface"; import { GTMEvent, gtmTrack } from "shared/utils/gtm"; @@ -20,13 +20,13 @@ import { CLOUD_MODE_KEY, useCloudPlan } from "shared/useCloudPlan"; // 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" + const [selectedItem, setSelectedItem] = React.useState(() => + usePageType(language, pageUrl) ); // update in client side React.useEffect(() => { - setSelectedItem(getPageType(language, window.location.pathname)); + setSelectedItem(usePageType(language, window.location.pathname)); }, [language]); return selectedItem; @@ -86,7 +86,7 @@ export function HeaderNavStackMobile(props: { buildType?: BuildType }) { calcPDFUrl(pathConfig), [pathConfig]); const pageType = React.useMemo( - () => getPageType(language, pageUrl), + () => usePageType(language, pageUrl), [pageUrl] ); diff --git a/src/components/Layout/TitleAction/TitleAction.tsx b/src/components/Layout/TitleAction/TitleAction.tsx index 481fd11f..d03f4c7b 100644 --- a/src/components/Layout/TitleAction/TitleAction.tsx +++ b/src/components/Layout/TitleAction/TitleAction.tsx @@ -19,7 +19,7 @@ import FileIcon from "media/icons/file.svg"; import { BuildType, PathConfig } from "shared/interface"; import { calcPDFUrl, getRepoFromPathCfg } from "shared/utils"; -import { getPageType } from "shared/getPageType"; +import { usePageType, PageType } from "shared/usePageType"; import { Tooltip, Divider } from "@mui/material"; interface TitleActionProps { @@ -39,7 +39,7 @@ export const TitleAction = (props: TitleActionProps) => { const [copied, setCopied] = React.useState(false); const isArchive = buildType === "archive"; const pageType = React.useMemo( - () => getPageType(language, pageUrl), + () => usePageType(language, pageUrl), [pageUrl] ); @@ -250,7 +250,7 @@ export const TitleAction = (props: TitleActionProps) => { )} {/* Download PDF */} - {pageType === "tidb" && language !== "ja" && ( + {pageType === PageType.TiDB && language !== "ja" && ( } endIcon={ - } diff --git a/src/components/Layout/RightNav/RightNav.bk.tsx b/src/components/Layout/RightNav/RightNav.bk.tsx index 87211618..0ee6bae9 100644 --- a/src/components/Layout/RightNav/RightNav.bk.tsx +++ b/src/components/Layout/RightNav/RightNav.bk.tsx @@ -9,8 +9,8 @@ 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 ChevronDownIcon from "media/icons/chevron-down.svg"; import QuestionAnswerIcon from "@mui/icons-material/QuestionAnswer"; import EditIcon from "@mui/icons-material/Edit"; import GitHubIcon from "@mui/icons-material/GitHub"; @@ -350,7 +350,7 @@ export function RightNavMobile(props: RightNavProps) { aria-haspopup="true" aria-expanded={open ? "true" : undefined} onClick={handleClick} - endIcon={} + endIcon={} sx={{ width: "100%", }} diff --git a/src/components/Layout/RightNav/RightNav.tsx b/src/components/Layout/RightNav/RightNav.tsx index 130975ec..d9ab3982 100644 --- a/src/components/Layout/RightNav/RightNav.tsx +++ b/src/components/Layout/RightNav/RightNav.tsx @@ -7,9 +7,8 @@ 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"; @@ -224,7 +223,7 @@ export function RightNavMobile(props: RightNavProps) { aria-haspopup="true" aria-expanded={open ? "true" : undefined} onClick={handleClick} - endIcon={} + endIcon={} sx={{ width: "100%", }} diff --git a/src/components/Layout/TitleAction/TitleAction.tsx b/src/components/Layout/TitleAction/TitleAction.tsx index d03f4c7b..8db1c3a3 100644 --- a/src/components/Layout/TitleAction/TitleAction.tsx +++ b/src/components/Layout/TitleAction/TitleAction.tsx @@ -10,12 +10,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 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, getRepoFromPathCfg } from "shared/utils"; @@ -146,7 +145,7 @@ export const TitleAction = (props: TitleActionProps) => { onClick={handleContributeClick} startIcon={} endIcon={ - } diff --git a/src/media/icons/chevron-down.svg b/src/media/icons/chevron-down.svg new file mode 100644 index 00000000..b912fc63 --- /dev/null +++ b/src/media/icons/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/shared/usePageType.ts b/src/shared/usePageType.ts index fd2edeb2..9ba735f2 100644 --- a/src/shared/usePageType.ts +++ b/src/shared/usePageType.ts @@ -1,3 +1,5 @@ +import { Locale } from "./interface"; + export enum PageType { Home = "home", TiDB = "tidb", @@ -9,48 +11,114 @@ export enum PageType { Release = "release", } +const LANGUAGE_CODES = Object.values(Locale); + +/** + * Normalize pageUrl by removing language prefix and extracting the segment to check + * Returns the segment that should be checked for pageType (second segment if language exists, first otherwise) + */ +const normalizePageUrl = (pageUrl: string): string => { + // Remove leading and trailing slashes, then split + const segments = pageUrl + .replace(/^\/+|\/+$/g, "") + .split("/") + .filter(Boolean); + + if (segments.length === 0) { + return ""; + } + + // Check if first segment is a language code + const firstSegment = segments[0]; + if (LANGUAGE_CODES.includes(firstSegment as Locale)) { + // If first segment is language, return second segment (or empty if only language) + return segments.length > 1 ? segments[1] : ""; + } + + // No language prefix, return first segment + return firstSegment; +}; + +/** + * Check if the normalized segment matches a pageType + * Matches if segment equals pageType exactly + * Also checks if the full URL path contains /{pageType} at the end or followed by / + */ +const matchesPageType = ( + segment: string, + pageType: string, + fullUrl: string +): boolean => { + if (!segment) { + return false; + } + + // Exact match of the segment + if (segment === pageType) { + return true; + } + + // Check if URL contains /{pageType} at the end or followed by / + // This handles cases like /tidb/stable/... or /en/tidb/stable/... + const pattern = new RegExp(`/${pageType}(/|$)`); + return pattern.test(fullUrl); +}; + export const usePageType = (language?: string, pageUrl?: string): PageType => { if (!pageUrl) { return PageType.Home; } // Check for home page - if (pageUrl === "/" || pageUrl === `/${language}/`) { + if ( + pageUrl === "/" || + pageUrl === `/${language}/` || + pageUrl === `/${language}` + ) { + return PageType.Home; + } + + // Normalize URL to get the segment to check + const segment = normalizePageUrl(pageUrl); + + // If no segment after normalization, it's home + if (!segment) { return PageType.Home; } - // Check for release pages (should be checked before other paths) - if (pageUrl.includes("/release/")) { + // Check page types in priority order + // Check for release pages + if (matchesPageType(segment, PageType.Release, pageUrl)) { return PageType.Release; } // Check for api pages - if (pageUrl.includes("/api/")) { + if (matchesPageType(segment, PageType.Api, pageUrl)) { return PageType.Api; } // Check for developer pages - if (pageUrl.includes("/developer/")) { + if (matchesPageType(segment, PageType.Developer, pageUrl)) { return PageType.Developer; } // Check for best-practice pages - if (pageUrl.includes("/best-practice/")) { + if (matchesPageType(segment, PageType.BestPractice, pageUrl)) { return PageType.BestPractice; } // Check for tidb-in-kubernetes pages - if (pageUrl.includes("/tidb-in-kubernetes/")) { + if (matchesPageType(segment, PageType.TiDBInKubernetes, pageUrl)) { return PageType.TiDBInKubernetes; } // Check for tidbcloud pages - if (pageUrl.includes("/tidbcloud/")) { + if (matchesPageType(segment, PageType.TiDBCloud, pageUrl)) { return PageType.TiDBCloud; } // Check for tidb pages - if (pageUrl.includes("/tidb/")) { + if (matchesPageType(segment, PageType.TiDB, pageUrl)) { return PageType.TiDB; } From 5f19cd3cff918bd1e91871195e44584a706350dc Mon Sep 17 00:00:00 2001 From: Suhaha Date: Sat, 10 Jan 2026 23:08:53 +0800 Subject: [PATCH 14/37] feat: enhance HeaderNav with dynamic navigation configuration - Introduced a new navigation configuration system in HeaderNav, allowing for dynamic rendering of navigation items and groups based on conditions. - Added NavGroup and NavMenuItem components to improve the structure and maintainability of the navigation logic. - Implemented a default navigation configuration generator to streamline the setup of navigation items based on user cloud plans. - Updated imports and added new icons for improved visual representation in the navigation menu. - Cleaned up unused code and optimized component rendering for better performance. --- src/components/Layout/Header/HeaderNav.tsx | 500 +++++++++++++++--- .../Layout/Header/HeaderNavConfig.ts | 45 ++ .../Layout/Header/HeaderNavConfigData.tsx | 174 ++++++ src/components/Layout/Header/LangSwitch.tsx | 6 +- src/media/icons/cloud-03.svg | 3 + src/media/icons/layers-three-01.svg | 10 + src/shared/interface.ts | 7 +- 7 files changed, 679 insertions(+), 66 deletions(-) create mode 100644 src/components/Layout/Header/HeaderNavConfig.ts create mode 100644 src/components/Layout/Header/HeaderNavConfigData.tsx create mode 100644 src/media/icons/cloud-03.svg create mode 100644 src/media/icons/layers-three-01.svg diff --git a/src/components/Layout/Header/HeaderNav.tsx b/src/components/Layout/Header/HeaderNav.tsx index 570b66df..b133d154 100644 --- a/src/components/Layout/Header/HeaderNav.tsx +++ b/src/components/Layout/Header/HeaderNav.tsx @@ -4,12 +4,18 @@ 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 { usePageType, PageType } from "shared/usePageType"; import { BuildType } from "shared/interface"; import { GTMEvent, gtmTrack } from "shared/utils/gtm"; -import { CLOUD_MODE_KEY, useCloudPlan } from "shared/useCloudPlan"; +import { useCloudPlan } from "shared/useCloudPlan"; +import ChevronDownIcon from "media/icons/chevron-down.svg"; +import { NavConfig, NavGroupConfig, NavItemConfig } from "./HeaderNavConfig"; +import { generateNavConfig } from "./HeaderNavConfigData"; // `pageUrl` comes from server side render (or build): gatsby/path.ts/generateUrl // it will be `undefined` in client side render @@ -30,114 +36,484 @@ const useSelectedNavItem = (language?: string, pageUrl?: string) => { export default function HeaderNavStack(props: { buildType?: BuildType; pageUrl?: string; + config?: NavConfig[]; }) { const { language, t } = useI18next(); const selectedItem = useSelectedNavItem(language, props.pageUrl); 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]); + return ( - {props.buildType !== "archive" && ( - { + // Check condition + if ( + navConfig.condition && + !navConfig.condition(language, props.buildType) + ) { + return null; + } + + return ( + + ); + })} + + ); +} + +const NavGroup = (props: { config: NavConfig; selectedItem: PageType }) => { + const { config, selectedItem } = 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(selectedItem) + : 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(selectedItem) + : 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(selectedItem) + : nestedChild.selected ?? false; + return nestedSelected; + } + return false; + }); } - /> - )} + }) + : false; - + - {/* {["zh"].includes(language) && ( - - )} + {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" + ); - {["en", "ja"].includes(language) && ( - - )} */} - - {/* {language === "zh" && ( - } - to={generateDownloadURL(language)} - alt="download" - startIcon={} - /> - )} */} - + 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) => ( + + ))} + + )} + + ); + })()} + + )} + ); -} +}; -const NavItem = (props: { - selected?: boolean; - label?: string | React.ReactElement; - to: string; - startIcon?: React.ReactNode; - alt?: string; - onClick?: () => void; +// 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; + selectedItem: PageType; + onClose: () => void; +}) => { + const { item, groupTitle, selectedItem, onClose } = props; + const isSelected = + typeof item.selected === "function" + ? item.selected(selectedItem) + : item.selected ?? false; + + return ( + { + gtmTrack(GTMEvent.ClickHeadNav, { + item_name: item.label || item.alt, + }); + }} + > + { + onClose(); + item.onClick?.(); + }} + disableRipple + selected={isSelected} sx={{ - display: "flex", - alignItems: "center", - paddingTop: "0.25rem", - paddingBottom: props.selected ? "0" : "0.25rem", - borderBottom: props.selected - ? `4px solid ${theme.palette.primary.main}` - : ``, + 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 { gtmTrack(GTMEvent.ClickHeadNav, { - item_name: props.label || props.alt, + item_name: label || alt, }); - - props.onClick?.(); + if (isItem) { + (config as NavItemConfig).onClick?.(); + } }} > - {props.startIcon} - {props.label} + {startIcon} + {label} - + ) : ( + // Render as button for group (with or without popover) + + {startIcon && ( + + {startIcon} + + )} + {label && ( + + {label} + + )} + {shouldShowPopover && ( + + )} + + )} ); }; diff --git a/src/components/Layout/Header/HeaderNavConfig.ts b/src/components/Layout/Header/HeaderNavConfig.ts new file mode 100644 index 00000000..26bf86f4 --- /dev/null +++ b/src/components/Layout/Header/HeaderNavConfig.ts @@ -0,0 +1,45 @@ +import { ReactNode } from "react"; +import { PageType } from "shared/usePageType"; + +/** + * 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 | ((pageType: PageType) => 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/HeaderNavConfigData.tsx b/src/components/Layout/Header/HeaderNavConfigData.tsx new file mode 100644 index 00000000..dfe57ae8 --- /dev/null +++ b/src/components/Layout/Header/HeaderNavConfigData.tsx @@ -0,0 +1,174 @@ +import { NavConfig } from "./HeaderNavConfig"; +import { PageType } from "shared/usePageType"; +import { CLOUD_MODE_KEY } from "shared/useCloudPlan"; +import { CloudPlan } 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: (pageType) => + pageType === PageType.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: (pageType) => + pageType === PageType.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: (pageType) => + pageType === PageType.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: (pageType) => + pageType === PageType.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: (pageType) => pageType === PageType.TiDB, + }, + { + type: "item", + label: "TiDB Self-Managed on Kubernetes", + to: "/tidb-in-kubernetes/stable", + selected: (pageType) => pageType === PageType.TiDBInKubernetes, + }, + ], + }, + ], + }, + { + type: "item", + label: "Developer", + to: "/developer", + selected: (pageType) => pageType === PageType.Developer, + }, + { + type: "item", + label: "Best Practices", + to: "/best-practice", + selected: (pageType) => pageType === PageType.BestPractice, + }, + { + type: "item", + label: "API", + to: "/api", + selected: (pageType) => pageType === PageType.Api, + }, + { + type: "group", + title: "Releases", + children: [ + { + type: "item", + label: "TiDB Cloud Releases", + to: "/release/tidbcloud", + selected: (pageType) => pageType === PageType.Release, + }, + { + type: "item", + label: "TiDB Self-Managed Releases", + to: "/release/tidb", + selected: (pageType) => pageType === PageType.Release, + }, + { + type: "item", + label: "TiDB Operator Releases", + to: "/release/tidb-in-kubernetes", + selected: (pageType) => pageType === PageType.Release, + }, + { + type: "item", + label: "TiUP Releases", + to: "/release/tiup", + selected: (pageType) => pageType === PageType.Release, + }, + ], + }, +]; + +/** + * Archive navigation configuration (only TiDB Self-Managed) + */ +const archiveNavConfig: NavConfig[] = [ + { + type: "item", + label: "TiDB Self-Managed", + to: "/tidb/v2.1", + selected: (pageType) => pageType === PageType.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/LangSwitch.tsx b/src/components/Layout/Header/LangSwitch.tsx index 7e65e4ab..a0bcf19c 100644 --- a/src/components/Layout/Header/LangSwitch.tsx +++ b/src/components/Layout/Header/LangSwitch.tsx @@ -111,7 +111,7 @@ export const LangSwitch = (props: { selected={language === Locale.en} disabled={!supportedLocales.includes(Locale.en)} > - + @@ -121,7 +121,7 @@ export const LangSwitch = (props: { selected={language === Locale.zh} disabled={!supportedLocales.includes(Locale.zh)} > - + @@ -131,7 +131,7 @@ export const LangSwitch = (props: { selected={language === Locale.ja} disabled={!supportedLocales.includes(Locale.ja)} > - + diff --git a/src/media/icons/cloud-03.svg b/src/media/icons/cloud-03.svg new file mode 100644 index 00000000..c9d24ae7 --- /dev/null +++ b/src/media/icons/cloud-03.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/media/icons/layers-three-01.svg b/src/media/icons/layers-three-01.svg new file mode 100644 index 00000000..97af7b2c --- /dev/null +++ b/src/media/icons/layers-three-01.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/shared/interface.ts b/src/shared/interface.ts index 97cd2b16..2aa88a47 100644 --- a/src/shared/interface.ts +++ b/src/shared/interface.ts @@ -68,4 +68,9 @@ export type RepoNav = RepoNavLink[]; export type BuildType = "prod" | "archive"; -export type CloudPlan = "dedicated" | "starter" | "essential" | "premium"; +export enum CloudPlan { + Dedicated = "dedicated", + Starter = "starter", + Essential = "essential", + Premium = "premium", +} From 48fdade4372bfde1a1e95c28c2830b21387112e9 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Sun, 11 Jan 2026 00:24:08 +0800 Subject: [PATCH 15/37] feat: enhance navigation components with selected item handling - Added onSelectedNavItemChange prop to Header, HeaderNavStack, and LeftNav components to manage selected navigation items. - Implemented a recursive function to find and notify the selected navigation item in HeaderNavStack. - Updated LeftNavDesktop to display the selected navigation item with improved styling and interaction. - Integrated selectedNavItem state management in DocTemplate for better navigation context. - Adjusted styles in various components for consistency and improved user experience. --- src/components/Layout/Header/HeaderNav.tsx | 33 +++++++++++++ src/components/Layout/Header/index.tsx | 9 +++- src/components/Layout/LeftNav/LeftNav.tsx | 48 +++++++++++++++++-- src/components/Layout/LeftNav/LeftNavTree.tsx | 30 +++++++----- .../Layout/VersionSelect/SharedSelect.tsx | 1 - src/components/Layout/index.tsx | 3 ++ src/templates/DocTemplate.tsx | 7 +++ src/theme/variables.css | 4 +- 8 files changed, 115 insertions(+), 20 deletions(-) diff --git a/src/components/Layout/Header/HeaderNav.tsx b/src/components/Layout/Header/HeaderNav.tsx index b133d154..4795c9be 100644 --- a/src/components/Layout/Header/HeaderNav.tsx +++ b/src/components/Layout/Header/HeaderNav.tsx @@ -33,10 +33,35 @@ const useSelectedNavItem = (language?: string, pageUrl?: string) => { return selectedItem; }; +// Helper function to find selected item recursively +const findSelectedItem = ( + configs: NavConfig[], + selectedItem: PageType +): NavItemConfig | null => { + for (const config of configs) { + if (config.type === "item") { + const isSelected = + typeof config.selected === "function" + ? config.selected(selectedItem) + : config.selected ?? false; + if (isSelected) { + return config; + } + } else if (config.type === "group" && config.children) { + const item = findSelectedItem(config.children, selectedItem); + if (item) { + return item; + } + } + } + return null; +}; + export default function HeaderNavStack(props: { buildType?: BuildType; pageUrl?: string; config?: NavConfig[]; + onSelectedNavItemChange?: (item: NavItemConfig | null) => void; }) { const { language, t } = useI18next(); const selectedItem = useSelectedNavItem(language, props.pageUrl); @@ -51,6 +76,14 @@ export default function HeaderNavStack(props: { 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, selectedItem); + props.onSelectedNavItemChange(selectedNavItem); + } + }, [defaultConfig, selectedItem, props.onSelectedNavItemChange]); + return ( void; } export default function Header(props: HeaderProps) { @@ -106,7 +109,11 @@ export default function Header(props: HeaderProps) { height: HEADER_HEIGHT.SECOND_ROW, }} > - + {props.locales.length > 0 && ( diff --git a/src/components/Layout/LeftNav/LeftNav.tsx b/src/components/Layout/LeftNav/LeftNav.tsx index 9d6f4660..1c90a719 100644 --- a/src/components/Layout/LeftNav/LeftNav.tsx +++ b/src/components/Layout/LeftNav/LeftNav.tsx @@ -4,11 +4,14 @@ 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 { NavItemConfig } from "../Header/HeaderNavConfig"; import LinkComponent from "components/Link"; import LeftNavTree from "./LeftNavTree"; import VersionSelect, { @@ -28,6 +31,7 @@ interface LeftNavProps { buildType?: BuildType; bannerEnabled?: boolean; availablePlans: string[]; + selectedNavItem?: NavItemConfig | null; } export function LeftNavDesktop(props: LeftNavProps) { @@ -39,7 +43,9 @@ export function LeftNavDesktop(props: LeftNavProps) { availIn, buildType, availablePlans, + selectedNavItem, } = props; + const theme = useTheme(); return ( - {pathConfig.repo !== "tidbcloud" && ( + {selectedNavItem && ( + + + + {selectedNavItem.label} + + + + )} + + {pathConfig.repo === "tidb" && ( )} - {pathConfig.repo === "tidbcloud" && ( + {/* {pathConfig.repo === "tidbcloud" && ( - )} + )} */} diff --git a/src/components/Layout/LeftNav/LeftNavTree.tsx b/src/components/Layout/LeftNav/LeftNavTree.tsx index 4f033ae8..c6c74720 100644 --- a/src/components/Layout/LeftNav/LeftNavTree.tsx +++ b/src/components/Layout/LeftNav/LeftNavTree.tsx @@ -5,6 +5,7 @@ import TreeView from "@mui/lab/TreeView"; import TreeItem, { TreeItemProps, treeItemClasses } from "@mui/lab/TreeItem"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; +import Divider from "@mui/material/Divider"; import { styled, useTheme } from "@mui/material/styles"; import { SvgIconProps } from "@mui/material/SvgIcon"; @@ -164,18 +165,25 @@ export default function ControlledTreeView(props: { return items.map((item) => { if (item.type === "heading") { return ( - - {item.content[0] as string} - + + {item.content[0] as string} + + + ); } else { return renderTreeItems([item]); diff --git a/src/components/Layout/VersionSelect/SharedSelect.tsx b/src/components/Layout/VersionSelect/SharedSelect.tsx index 52518581..64f9c8e6 100644 --- a/src/components/Layout/VersionSelect/SharedSelect.tsx +++ b/src/components/Layout/VersionSelect/SharedSelect.tsx @@ -23,7 +23,6 @@ export const VersionSelectButton = forwardRef( position: "sticky", top: "-20px", backgroundColor: "#fff", - marginTop: "-20px", marginLeft: "-16px", marginRight: "-16px", paddingTop: "20px", diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index a5e0d2c8..7d5bc9b1 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -9,6 +9,7 @@ import theme from "theme/index"; import Header from "components/Layout/Header"; import Footer from "components/Layout/Footer"; import { Locale, BuildType, PathConfig } from "shared/interface"; +import { NavItemConfig } from "components/Layout/Header/HeaderNavConfig"; export default function Layout(props: { children?: React.ReactNode; @@ -20,6 +21,7 @@ export default function Layout(props: { pageUrl?: string; name?: string; pathConfig?: PathConfig; + onSelectedNavItemChange?: (item: NavItemConfig | null) => void; }) { return ( @@ -33,6 +35,7 @@ export default function Layout(props: { pageUrl={props.pageUrl} name={props.name} pathConfig={props.pathConfig} + onSelectedNavItemChange={props.onSelectedNavItemChange} /> {props.children}
diff --git a/src/templates/DocTemplate.tsx b/src/templates/DocTemplate.tsx index 32644840..56219678 100644 --- a/src/templates/DocTemplate.tsx +++ b/src/templates/DocTemplate.tsx @@ -26,6 +26,7 @@ import { import Seo from "components/Seo"; import { getStable, generateUrl } from "shared/utils"; import { usePageType } from "shared/usePageType"; +import { NavItemConfig } from "components/Layout/Header/HeaderNavConfig"; import GitCommitInfoCard from "components/Card/GitCommitInfoCard"; import { FeedbackSection } from "components/Card/FeedbackSection"; import { FeedbackSurveyCampaign } from "components/Campaign/FeedbackSurvey"; @@ -160,6 +161,10 @@ function DocTemplate({ const bannerVisible = feature?.banner; const isGlobalHome = !!feature?.globalHome; + const [selectedNavItem, setSelectedNavItem] = React.useState< + NavItemConfig | null + >(null); + return ( )} Date: Mon, 12 Jan 2026 10:12:45 +0800 Subject: [PATCH 16/37] refactor: update HeaderAction and theme for improved button styling - Replaced ActionButton with Button in HeaderAction for consistency in component usage. - Added secondary color styling for outlined and contained button variants in the theme. - Adjusted button styles to enhance visual coherence and user experience across the application. --- src/components/Layout/Header/HeaderAction.tsx | 7 ++--- src/theme/index.tsx | 27 ++++++++++++------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/components/Layout/Header/HeaderAction.tsx b/src/components/Layout/Header/HeaderAction.tsx index a22e76dd..9e1a89bd 100644 --- a/src/components/Layout/Header/HeaderAction.tsx +++ b/src/components/Layout/Header/HeaderAction.tsx @@ -14,7 +14,6 @@ import CloudIcon from "@mui/icons-material/Cloud"; 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"; @@ -74,7 +73,7 @@ export default function HeaderAction(props: { <> {language === "en" && showTiDBAIButton && ( - } @@ -92,7 +91,7 @@ export default function HeaderAction(props: { }} > Ask TiDB.ai - + )} )} @@ -123,6 +122,7 @@ const TiDBCloudBtnGroup = () => { > )} {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 a4972391..2035e2fc 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", From 085f3f593b7b7f051be95b8b707cb1d6744c6c96 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Mon, 12 Jan 2026 11:09:52 +0800 Subject: [PATCH 18/37] refactor: improve LangSwitch and theme button styles for consistency - Simplified the LangSwitch component by removing unnecessary padding and adjusting the end icon styling. - Updated the theme to ensure consistent margin settings for button icons, enhancing overall button appearance across the application. --- src/components/Layout/Header/LangSwitch.tsx | 7 +------ src/theme/index.tsx | 6 ++++++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/components/Layout/Header/LangSwitch.tsx b/src/components/Layout/Header/LangSwitch.tsx index a0bcf19c..0ba03436 100644 --- a/src/components/Layout/Header/LangSwitch.tsx +++ b/src/components/Layout/Header/LangSwitch.tsx @@ -67,15 +67,10 @@ export const LangSwitch = (props: { onClick={handleClick} color="inherit" startIcon={} - endIcon={ - - } + endIcon={} size="large" sx={{ fontSize: "14px", - paddingLeft: "16px", display: { xs: "none", lg: "inline-flex", diff --git a/src/theme/index.tsx b/src/theme/index.tsx index 92246899..cbe20322 100644 --- a/src/theme/index.tsx +++ b/src/theme/index.tsx @@ -256,6 +256,12 @@ theme = createTheme(theme, { fontSize: "16px", }, root: ({ ownerState }) => ({ + ".MuiButton-startIcon": { + marginLeft: "0", + }, + ".MuiButton-endIcon": { + marginRight: "0", + }, ...(ownerState.variant === "text" && { color: theme.palette.text.primary, "&:hover": { From 8f5cb40069e258531e5c68349e5e9d4612beffd8 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Mon, 12 Jan 2026 11:16:26 +0800 Subject: [PATCH 19/37] refactor: simplify Box styling in NavGroup for improved readability - Streamlined Box component styles in NavGroup by removing unnecessary sx prop and directly applying display, flexDirection, and gap properties. - Enhanced the layout of child components for better visual consistency and maintainability. --- src/components/Layout/Header/HeaderNav.tsx | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/components/Layout/Header/HeaderNav.tsx b/src/components/Layout/Header/HeaderNav.tsx index 4795c9be..1b4cafa7 100644 --- a/src/components/Layout/Header/HeaderNav.tsx +++ b/src/components/Layout/Header/HeaderNav.tsx @@ -254,19 +254,14 @@ const NavGroup = (props: { config: NavConfig; selectedItem: PageType }) => { return ( <> {groups.length > 0 && ( - + {groups.map((child, index) => ( { )} {items.length > 0 && ( {items.map((child, index) => ( Date: Mon, 12 Jan 2026 11:58:54 +0800 Subject: [PATCH 20/37] refactor: enhance LeftNavTree with session storage for expanded IDs and scroll position - Removed custom StyledTreeItem in favor of MUI's TreeItem for consistency. - Implemented session storage handling for expanded tree nodes and scroll position restoration. - Updated theme styles for TreeItem to improve visual consistency and user experience. - Cleaned up unused imports and streamlined component logic for better maintainability. --- src/components/Layout/LeftNav/LeftNavTree.tsx | 232 ++++++++++++------ src/theme/index.tsx | 52 +++- src/theme/variables.css | 18 +- 3 files changed, 210 insertions(+), 92 deletions(-) diff --git a/src/components/Layout/LeftNav/LeftNavTree.tsx b/src/components/Layout/LeftNav/LeftNavTree.tsx index c6c74720..755f04f9 100644 --- a/src/components/Layout/LeftNav/LeftNavTree.tsx +++ b/src/components/Layout/LeftNav/LeftNavTree.tsx @@ -2,77 +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 Divider from "@mui/material/Divider"; -import { styled, useTheme } from "@mui/material/styles"; -import { SvgIconProps } from "@mui/material/SvgIcon"; +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, @@ -100,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 => { @@ -121,6 +65,79 @@ 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; +}; + export default function ControlledTreeView(props: { data: RepoNav; current: string; @@ -139,16 +156,51 @@ 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 || + (expandedIds.length > 0 + ? expandedIds[expandedIds.length - 1] + : 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 || + (expandedIds.length > 0 + ? expandedIds[expandedIds.length - 1] + : 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. @@ -157,9 +209,19 @@ 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) => { @@ -239,10 +301,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={{ @@ -252,7 +326,7 @@ export default function ControlledTreeView(props: { {hasChildren ? renderTreeItems(item.children as RepoNavLink[], deepth + 1) : null} - + ); }); @@ -260,6 +334,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 ( @@ -269,6 +347,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/theme/index.tsx b/src/theme/index.tsx index cbe20322..8c2c3d20 100644 --- a/src/theme/index.tsx +++ b/src/theme/index.tsx @@ -1,10 +1,7 @@ import * as React from "react"; -import { - createTheme, - PaletteColorOptions, - ThemeOptions, -} from "@mui/material/styles"; +import { createTheme, ThemeOptions } from "@mui/material/styles"; import { ColorPartial } from "@mui/material/styles/createPalette"; +import { treeItemClasses } from "@mui/lab/TreeItem"; declare module "@mui/material/styles" { interface Theme { @@ -172,15 +169,15 @@ theme = createTheme(theme, { carbon: theme.palette.augmentColor({ color: { 50: "#FFFFFF", - 100: "#FBFDFD", - 200: "#F5F8FA", - 300: "#EDF0F1", + 100: "#FDFEFF", + 200: "#F9FAFB", + 300: "#EDF1F2", 400: "#DCE3E5", - 500: "#C8CED0", + 500: "#C4CDD0", 600: "#9FA9AD", 700: "#6F787B", - 800: "#3D4143", - 900: "#262A2C", + 800: "#383E40", + 900: "#1E2426", }, name: "carbon", }), @@ -314,6 +311,39 @@ theme = createTheme(theme, { }, }, }, + MuiTreeItem: { + styleOverrides: { + root: { + marginTop: "4px", + marginBottom: "4px", + [`& .${treeItemClasses.content}`]: { + "&:hover": { + backgroundColor: theme.palette.carbon[300], + }, + "&.Mui-focused": { + backgroundColor: "#fff", + "&:hover": { + backgroundColor: theme.palette.carbon[300], + }, + }, + "&.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], + }, + }, + [`& .${treeItemClasses.iconContainer}`]: { + display: "none", + }, + }, + [`& .${treeItemClasses.group}`]: { + marginLeft: 0, + }, + }, + }, + }, }, } as ThemeOptions); diff --git a/src/theme/variables.css b/src/theme/variables.css index 2b9e1a61..7ee9fe1c 100644 --- a/src/theme/variables.css +++ b/src/theme/variables.css @@ -12,15 +12,15 @@ /* Carbon */ --tiui-palette-carbon-50: #ffffff; - --tiui-palette-carbon-100: #fbfdfd; - --tiui-palette-carbon-200: #f5f8fa; - --tiui-palette-carbon-300: #edf0f1; - --tiui-palette-carbon-400: #dce3e5; - --tiui-palette-carbon-500: #c8ced0; - --tiui-palette-carbon-600: #9faaad; - --tiui-palette-carbon-700: #6f787b; - --tiui-palette-carbon-800: #3d4143; - --tiui-palette-carbon-900: #262a2c; + --tiui-palette-carbon-100: #fdfeff; + --tiui-palette-carbon-200: #f9fafb; + --tiui-palette-carbon-300: #edf1f2; + --tiui-palette-carbon-400: #c4cdd0; + --tiui-palette-carbon-500: #9fa9ad; + --tiui-palette-carbon-600: #6f787b; + --tiui-palette-carbon-700: #383e40; + --tiui-palette-carbon-800: #1e2426; + --tiui-palette-carbon-900: #1e2426; /* Peacock */ --tiui-palette-peacock-50: #fbfdfe; From 44148c0aa546c0ba5135fb370a84039bdd7ca334 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Mon, 12 Jan 2026 12:24:01 +0800 Subject: [PATCH 21/37] feat: implement clearAllNavStates function for navigation state management - Added clearAllNavStates function to clear session storage for navigation states across components. - Integrated clearAllNavStates in NavMenuItem and NavButton click handlers to ensure consistent navigation behavior. - Updated LeftNav component to utilize clearAllNavStates on item selection for improved user experience. --- src/components/Layout/Header/HeaderNav.tsx | 3 ++ src/components/Layout/LeftNav/LeftNav.tsx | 7 ++-- src/components/Layout/LeftNav/LeftNavTree.tsx | 34 +++++++++++++++++++ src/theme/index.tsx | 17 +++++----- 4 files changed, 50 insertions(+), 11 deletions(-) diff --git a/src/components/Layout/Header/HeaderNav.tsx b/src/components/Layout/Header/HeaderNav.tsx index 1b4cafa7..a0185e54 100644 --- a/src/components/Layout/Header/HeaderNav.tsx +++ b/src/components/Layout/Header/HeaderNav.tsx @@ -16,6 +16,7 @@ import { useCloudPlan } from "shared/useCloudPlan"; import ChevronDownIcon from "media/icons/chevron-down.svg"; import { NavConfig, NavGroupConfig, NavItemConfig } from "./HeaderNavConfig"; import { generateNavConfig } from "./HeaderNavConfigData"; +import { clearAllNavStates } from "../LeftNav/LeftNavTree"; // `pageUrl` comes from server side render (or build): gatsby/path.ts/generateUrl // it will be `undefined` in client side render @@ -379,6 +380,7 @@ const NavMenuItem = (props: { > { + clearAllNavStates(); onClose(); item.onClick?.(); }} @@ -458,6 +460,7 @@ const NavButton = (props: { isI18n={isI18n} to={to} onClick={() => { + clearAllNavStates(); gtmTrack(GTMEvent.ClickHeadNav, { item_name: label || alt, }); diff --git a/src/components/Layout/LeftNav/LeftNav.tsx b/src/components/Layout/LeftNav/LeftNav.tsx index 1c90a719..627a0cfc 100644 --- a/src/components/Layout/LeftNav/LeftNav.tsx +++ b/src/components/Layout/LeftNav/LeftNav.tsx @@ -13,14 +13,13 @@ import MenuIcon from "@mui/icons-material/Menu"; import { RepoNav, PathConfig, BuildType } from "shared/interface"; import { NavItemConfig } from "../Header/HeaderNavConfig"; 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; @@ -42,7 +41,6 @@ export function LeftNavDesktop(props: LeftNavProps) { pathConfig, availIn, buildType, - availablePlans, selectedNavItem, } = props; const theme = useTheme(); @@ -85,6 +83,9 @@ export function LeftNavDesktop(props: LeftNavProps) { isI18n={selectedNavItem.isI18n ?? true} to={selectedNavItem.to} style={{ textDecoration: "none", display: "block" }} + onClick={() => { + clearAllNavStates(); + }} > { 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; diff --git a/src/theme/index.tsx b/src/theme/index.tsx index 8c2c3d20..03296aa0 100644 --- a/src/theme/index.tsx +++ b/src/theme/index.tsx @@ -320,20 +320,21 @@ theme = createTheme(theme, { "&:hover": { backgroundColor: theme.palette.carbon[300], }, - "&.Mui-focused": { + [`&.${treeItemClasses.focused}`]: { backgroundColor: "#fff", "&:hover": { backgroundColor: theme.palette.carbon[300], }, }, - "&.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-selected, &.Mui-selected:hover": { + backgroundColor: theme.palette.carbon[300], + [`& svg.MuiTreeItem-ChevronRightIcon`]: { + fill: theme.palette.carbon[700], }, + }, + [`&.${treeItemClasses.selected} .MuiTypography-root`]: { + fontWeight: 700, + }, [`& .${treeItemClasses.iconContainer}`]: { display: "none", }, From 3fff94711be1f52c4477e9ae9a7952c9fcde7158 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Mon, 12 Jan 2026 14:59:11 +0800 Subject: [PATCH 22/37] refactor: update VersionSelect component for improved styling and consistency - Added disableRipple property to MenuItem components for a cleaner interaction experience. - Simplified Divider components by removing unnecessary margin settings. - Enhanced theme styles for MuiMenu to improve layout and item interaction, including active state styling and list organization. - Streamlined MenuItem styles for better visual consistency across the VersionSelect component. --- .../Layout/VersionSelect/VersionSelect.tsx | 12 ++++++--- src/theme/index.tsx | 25 ++++++++++++++++++- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/components/Layout/VersionSelect/VersionSelect.tsx b/src/components/Layout/VersionSelect/VersionSelect.tsx index 436464f4..1203de5f 100644 --- a/src/components/Layout/VersionSelect/VersionSelect.tsx +++ b/src/components/Layout/VersionSelect/VersionSelect.tsx @@ -71,6 +71,7 @@ const VersionItems = (props: { return ( <> - + {pathConfig.repo === "tidb" && ( Long-Term Support @@ -108,6 +108,7 @@ const VersionItems = (props: { )} {LTSVersions.map((version) => ( ))} - + {pathConfig.repo === "tidb" && DMRVersions.length > 0 && ( <> @@ -145,6 +146,7 @@ const VersionItems = (props: { {DMRVersions.map((version) => ( ))} - + )} {archiveList.map((version) => ( Date: Mon, 12 Jan 2026 15:08:25 +0800 Subject: [PATCH 23/37] refactor: enhance theme styles for selected TreeItem states - Updated the styling for selected TreeItem states to include focused and hover states for improved visual feedback. - Ensured consistent background color and icon fill for selected items, enhancing user experience in the navigation tree. --- src/theme/index.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/theme/index.tsx b/src/theme/index.tsx index cff46664..0fb17bcc 100644 --- a/src/theme/index.tsx +++ b/src/theme/index.tsx @@ -349,12 +349,13 @@ theme = createTheme(theme, { backgroundColor: theme.palette.carbon[300], }, }, - "&.Mui-selected, &.Mui-selected:hover": { - backgroundColor: theme.palette.carbon[300], - [`& svg.MuiTreeItem-ChevronRightIcon`]: { - fill: theme.palette.carbon[700], + "&.Mui-selected, &.Mui-selected.Mui-focused, &.Mui-selected:hover": + { + backgroundColor: theme.palette.carbon[300], + [`& svg.MuiTreeItem-ChevronRightIcon`]: { + fill: theme.palette.carbon[700], + }, }, - }, [`&.${treeItemClasses.selected} .MuiTypography-root`]: { fontWeight: 700, }, From 06485dd31a34c402f790ab0a6fd76cab65f526c2 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Tue, 13 Jan 2026 00:51:56 +0800 Subject: [PATCH 24/37] refactor: update CloudPlan imports and usage for consistency - Changed the import path for CloudPlan to align with the project structure. - Replaced string literals with CloudPlan enum values in the getTidbCloudFilesFromTocs and determineInDefaultPlan functions for improved type safety and consistency. --- gatsby/cloud-plan.ts | 18 +++++++++--------- .../MDXComponents/EmailSubscriptionForm.tsx | 1 + 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/gatsby/cloud-plan.ts b/gatsby/cloud-plan.ts index fd2e9c49..08feb000 100644 --- a/gatsby/cloud-plan.ts +++ b/gatsby/cloud-plan.ts @@ -1,7 +1,7 @@ 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"; type TocMap = Map< string, @@ -59,13 +59,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 +118,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 +132,7 @@ export function determineInDefaultPlan( !dedicated.has(fileName) && !starter.has(fileName) ) { - return "essential"; + return CloudPlan.Essential; } if ( @@ -141,7 +141,7 @@ export function determineInDefaultPlan( !dedicated.has(fileName) && !starter.has(fileName) ) { - return "premium"; + return CloudPlan.Premium; } return null; diff --git a/src/components/MDXComponents/EmailSubscriptionForm.tsx b/src/components/MDXComponents/EmailSubscriptionForm.tsx index 697654e7..aeea524e 100644 --- a/src/components/MDXComponents/EmailSubscriptionForm.tsx +++ b/src/components/MDXComponents/EmailSubscriptionForm.tsx @@ -104,6 +104,7 @@ function EmailSubscriptionForm() { Date: Wed, 14 Jan 2026 11:04:06 +0800 Subject: [PATCH 25/37] Feat/link resolver v1 (#669) * refactor: update CloudPlan imports and usage for consistency - Changed the import path for CloudPlan to align with the project structure. - Replaced string literals with CloudPlan enum values in the getTidbCloudFilesFromTocs and determineInDefaultPlan functions for improved type safety and consistency. * refactor: enhance URL resolver with new link and path mapping utilities - Introduced a comprehensive URL resolver module to map source file paths to published URLs. - Added support for branch aliasing and pattern matching for dynamic URL generation. - Implemented link resolution for markdown links, improving navigation consistency. - Created configuration and utility files for managing path and link mappings, enhancing maintainability and scalability. * feat: add Jest configuration and enhance URL resolution utilities - Introduced Jest configuration for TypeScript tests to improve testing capabilities. - Updated package.json to include Jest and ts-jest dependencies for TypeScript support. - Refactored URL resolution logic to utilize new link resolver utilities, enhancing markdown link handling. - Deprecated the old URL generation method in favor of the new calculateFileUrl function for better maintainability. - Added comprehensive tests for the new URL resolver functionalities, ensuring robust link resolution and alias handling. * refactor: enhance link resolver configuration and logic for improved mapping - Updated link resolver configuration to clarify direct and path-based mapping rules. - Refactored link resolution logic to streamline the processing of link mappings and conditions. - Improved handling of namespace transformations and default language settings in URL generation. - Enhanced type definitions for better clarity on mapping rules and conditions. * refactor: enhance URL resolution logic and link handling for tidbcloud - Updated URL resolver configuration to support dedicated _index files for tidbcloud, improving path mapping accuracy. - Refactored link resolution logic to handle branch-specific paths and filename transformations more effectively. - Enhanced test cases to cover new URL patterns and ensure robust handling of markdown links and fallback scenarios. - Cleaned up deprecated rules and improved overall maintainability of the URL resolver module. * refactor: update mdxAstToToc function to use slug for path resolution - Modified the mdxAstToToc function to accept slug instead of config for improved clarity in path resolution. - Updated related functions in create-types and cloud-plan to ensure consistent usage of slug for TOC generation. - Cleaned up deprecated code and improved overall maintainability of the TOC handling logic. * refactor: simplify selectedId logic in LeftNavTree component - Streamlined the assignment of selectedId by removing unnecessary conditional checks, ensuring it defaults to storedId or undefined. - Improved code clarity and maintainability in the ControlledTreeView function by reducing complexity in state management. * refactor: remove unnecessary logging in URL resolution logic - Eliminated debug logging related to currentFileUrl and resolvedPath in the URL resolution process for tidbcloud. - Improved code cleanliness and maintainability by reducing clutter in the content plugin's index file. * refactor: update navigation and frontmatter handling in Gatsby setup - Replaced createExtraType with createFrontmatter and createNavs in gatsby-node.js for improved type management. - Introduced new functions for generating navigation and frontmatter types, enhancing the structure of Markdown nodes. - Updated URL generation logic in path.ts to include shared namespace handling for better path resolution. - Refactored navigation generation in create-doc-home and create-docs to utilize the new navigation functions. - Improved overall code clarity and maintainability by consolidating navigation logic and enhancing type definitions. * feat: add new link resolver configuration for Tidb documentation - Introduced a new path pattern for Tidb documentation in the link resolver configuration, allowing for better URL mapping. - Enhanced link resolution logic to support specific folder conditions, improving navigation for documentation categories such as "develop," "best-practice," "api," and "releases." - Integrated configuration data from the docs.json file to streamline the management of documentation versions. --- docs | 2 +- gatsby-node.js | 5 +- gatsby/cloud-plan.ts | 20 +- gatsby/create-pages/create-doc-home.ts | 8 +- gatsby/create-pages/create-docs.ts | 21 +- gatsby/create-pages/interface.ts | 4 + gatsby/create-types/create-frontmatter.ts | 29 + .../create-navs.ts} | 43 +- gatsby/create-types/index.ts | 2 + .../__tests__/link-resolver.test.ts | 564 ++++++ gatsby/link-resolver/config.ts | 63 + gatsby/link-resolver/index.ts | 20 + gatsby/link-resolver/link-resolver.ts | 165 ++ gatsby/link-resolver/types.ts | 30 + gatsby/path.ts | 41 +- gatsby/plugin/content/index.ts | 153 +- gatsby/toc-filter.ts | 2 +- gatsby/toc.ts | 32 +- gatsby/url-resolver/__tests__/README.md | 65 + .../__tests__/branch-alias.test.ts | 168 ++ .../__tests__/pattern-matcher.test.ts | 232 +++ .../__tests__/url-resolver.test.ts | 556 ++++++ gatsby/url-resolver/branch-alias.ts | 191 ++ gatsby/url-resolver/config.ts | 114 ++ gatsby/url-resolver/index.ts | 28 + gatsby/url-resolver/pattern-matcher.ts | 203 ++ gatsby/url-resolver/types.ts | 86 + gatsby/url-resolver/url-resolver.ts | 269 +++ jest.config.js | 17 + package.json | 7 +- src/components/Layout/Header/HeaderNav.tsx | 9 +- .../Layout/Header/HeaderNavConfigData.tsx | 42 +- ...derNavConfig.ts => HeaderNavConfigType.ts} | 0 src/components/Layout/Header/index.tsx | 2 +- src/components/Layout/LeftNav/LeftNav.tsx | 8 +- src/components/Layout/LeftNav/LeftNavTree.tsx | 12 +- src/components/Layout/index.tsx | 2 +- .../MDXComponents/EmailSubscriptionForm.tsx | 1 + src/shared/usePageType.ts | 12 +- src/templates/DocTemplate.tsx | 8 +- yarn.lock | 1668 ++++++++++++++++- 41 files changed, 4669 insertions(+), 235 deletions(-) create mode 100644 gatsby/create-types/create-frontmatter.ts rename gatsby/{create-types.ts => create-types/create-navs.ts} (72%) create mode 100644 gatsby/create-types/index.ts create mode 100644 gatsby/link-resolver/__tests__/link-resolver.test.ts create mode 100644 gatsby/link-resolver/config.ts create mode 100644 gatsby/link-resolver/index.ts create mode 100644 gatsby/link-resolver/link-resolver.ts create mode 100644 gatsby/link-resolver/types.ts create mode 100644 gatsby/url-resolver/__tests__/README.md create mode 100644 gatsby/url-resolver/__tests__/branch-alias.test.ts create mode 100644 gatsby/url-resolver/__tests__/pattern-matcher.test.ts create mode 100644 gatsby/url-resolver/__tests__/url-resolver.test.ts create mode 100644 gatsby/url-resolver/branch-alias.ts create mode 100644 gatsby/url-resolver/config.ts create mode 100644 gatsby/url-resolver/index.ts create mode 100644 gatsby/url-resolver/pattern-matcher.ts create mode 100644 gatsby/url-resolver/types.ts create mode 100644 gatsby/url-resolver/url-resolver.ts create mode 100644 jest.config.js rename src/components/Layout/Header/{HeaderNavConfig.ts => HeaderNavConfigType.ts} (100%) diff --git a/docs b/docs index 631b8250..59aad6c4 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 631b8250ef16f47f73c2387221b9d8a18107f7f1 +Subproject commit 59aad6c4afb90f7395671e90485ff4295b932a81 diff --git a/gatsby-node.js b/gatsby-node.js index 6d01d906..184065bb 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 fd2e9c49..4657a5a2 100644 --- a/gatsby/cloud-plan.ts +++ b/gatsby/cloud-plan.ts @@ -1,7 +1,7 @@ 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"; type TocMap = Map< string, @@ -46,7 +46,7 @@ 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 toc = mdxAstToToc(node.mdxAST.children, node.slug); const files = extractFilesFromToc(toc); // Create a key for this specific locale/repo/version combination @@ -59,13 +59,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 +118,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 +132,7 @@ export function determineInDefaultPlan( !dedicated.has(fileName) && !starter.has(fileName) ) { - return "essential"; + return CloudPlan.Essential; } if ( @@ -141,7 +141,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 76f5618c..c7df0527 100644 --- a/gatsby/create-pages/create-doc-home.ts +++ b/gatsby/create-pages/create-doc-home.ts @@ -8,8 +8,6 @@ import { generateConfig, generateNav, generateDocHomeUrl, - generateStarterNav, - generateEssentialNav, } from "../../gatsby/path"; import { DEFAULT_BUILD_TYPE, PageQueryData } from "./interface"; @@ -86,9 +84,9 @@ 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 navUrl = generateNav(pathConfig, slug); + const starterNavUrl = generateNav(pathConfig, "tidb-cloud-starter"); + const essentialNavUrl = generateNav(pathConfig, "tidb-cloud-essential"); const locale = process.env.WEBSITE_BUILD_TYPE === "archive" ? [Locale.en, Locale.zh] diff --git a/gatsby/create-pages/create-docs.ts b/gatsby/create-pages/create-docs.ts index ca266fbf..83a86e06 100644 --- a/gatsby/create-pages/create-docs.ts +++ b/gatsby/create-pages/create-docs.ts @@ -6,11 +6,10 @@ import sig from "signale"; import { Locale, Repo, BuildType } from "../../src/shared/interface"; import { generateConfig, - generateUrl, generateNav, - generateStarterNav, - generateEssentialNav, + getSharedNamespace, } from "../../gatsby/path"; +import { calculateFileUrl } from "../../gatsby/url-resolver"; import { cpMarkdown } from "../../gatsby/cp-markdown"; import { getTidbCloudFilesFromTocs, @@ -112,10 +111,18 @@ 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 = getSharedNamespace(node.slug); + const navUrl = generateNav(pathConfig, namespace); + const starterNavUrl = generateNav(pathConfig, "tidb-cloud-starter"); + const essentialNavUrl = generateNav(pathConfig, "tidb-cloud-essential"); const locale = [Locale.en, Locale.zh, Locale.ja] .map((l) => diff --git a/gatsby/create-pages/interface.ts b/gatsby/create-pages/interface.ts index a87d2723..8f5be233 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 00000000..993e7629 --- /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 b2c6e405..7ef38f8b 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 { generateConfig } from "../path"; +import { mdxAstToToc } from "../toc"; +import { Root } from "mdast"; -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,7 @@ 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 res = mdxAstToToc(mdxAST.children, slug, undefined, true); mdxNode.nav = res; return res; }, @@ -93,8 +66,7 @@ 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 res = mdxAstToToc(mdxAST.children, slug, undefined, true); mdxNode.starterNav = res; return res; }, @@ -129,8 +101,7 @@ 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 res = mdxAstToToc(mdxAST.children, 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 00000000..c31f8a93 --- /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 00000000..001c0487 --- /dev/null +++ b/gatsby/link-resolver/__tests__/link-resolver.test.ts @@ -0,0 +1,564 @@ +/** + * 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", () => { + 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 best-practice namespace links", () => { + const result = resolveMarkdownLink( + "/best-practice/optimization/query-optimization", + "/en/tidb/stable/alert-rules" + ); + expect(result).toBe("/best-practice/query-optimization"); + }); + + it("should resolve api namespace links", () => { + const result = resolveMarkdownLink( + "/api/overview/introduction", + "/en/tidb/stable/alert-rules" + ); + expect(result).toBe("/api/introduction"); + }); + + it("should resolve releases namespace links", () => { + const result = resolveMarkdownLink( + "/releases/v8.5/release-notes", + "/en/tidb/stable/alert-rules" + ); + expect(result).toBe("/releases/release-notes"); + }); + + it("should transform tidb-cloud to tidbcloud", () => { + const result = resolveMarkdownLink( + "/tidb-cloud/dedicated/getting-started", + "/en/tidb/stable/alert-rules" + ); + expect(result).toBe("/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", () => { + const result = resolveMarkdownLink( + "/develop/a/b/c/d/e/page", + "/en/tidb/stable/alert-rules" + ); + expect(result).toBe("/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" + ); + expect(result).toBe("/tidb/stable/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" + ); + expect(result).toBe("/tidb/stable/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" + ); + expect(result).toBe("/tidb/stable/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("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 00000000..0d2de17b --- /dev/null +++ b/gatsby/link-resolver/config.ts @@ -0,0 +1,63 @@ +/** + * Default link resolver configuration + */ + +import type { LinkResolverConfig } from "./types"; +import CONFIG from "../../docs/docs.json"; + +export const defaultLinkResolverConfig: LinkResolverConfig = { + // Default language to omit from resolved URLs + defaultLanguage: "en", + + linkMappings: [ + // Rule 1: Links starting with specific namespaces (direct link mapping) + // /{namespace}/{...any}/{docname} -> /{namespace}/{docname} + // Special: tidb-cloud -> tidbcloud + { + linkPattern: "/{namespace}/{...any}/{docname}", + targetPattern: "/{namespace}/{docname}", + conditions: { + namespace: [ + "tidb-cloud", + "develop", + "best-practice", + "api", + "releases", + ], + }, + 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", "releases"], + }, + 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 00000000..0460a041 --- /dev/null +++ b/gatsby/link-resolver/index.ts @@ -0,0 +1,20 @@ +/** + * 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, + LinkMappingByPath, + LinkResolverConfig, +} from "./types"; + +// Export link resolver functions +export { resolveMarkdownLink } 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 00000000..8d2e9b94 --- /dev/null +++ b/gatsby/link-resolver/link-resolver.ts @@ -0,0 +1,165 @@ +/** + * 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"; + +/** + * Parse link path into segments + */ +function parseLinkPath(linkPath: string): string[] { + // Remove leading and trailing slashes, then split + const normalized = linkPath.replace(/^\/+|\/+$/g, ""); + if (!normalized) { + return []; + } + return normalized.split("/").filter((s) => s.length > 0); +} + +/** + * 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 { + const linkConfig = defaultLinkResolverConfig; + const urlConfig = defaultUrlResolverConfig; + if (!linkPath || linkPath.startsWith("http") || linkPath.startsWith("#")) { + // Skip external links and anchor links + return linkPath; + } + + // Normalize link path + const normalizedLink = linkPath.startsWith("/") ? linkPath : "/" + linkPath; + const linkSegments = parseLinkPath(normalizedLink); + + if (linkSegments.length === 0) { + return linkPath; + } + + // Process all rules in order (first match wins) + const currentPageSegments = parseLinkPath(currentPageUrl); + + 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; + } + + // 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 }; + + // 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(/\/$/, ""); + } + + return result; + } + + // No match found, return original link + return linkPath; +} diff --git a/gatsby/link-resolver/types.ts b/gatsby/link-resolver/types.ts new file mode 100644 index 00000000..8b4090e5 --- /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.ts b/gatsby/path.ts index 9e5ed695..d045a617 100644 --- a/gatsby/path.ts +++ b/gatsby/path.ts @@ -1,6 +1,7 @@ import { Locale, Repo, PathConfig, CloudPlan } from "../src/shared/interface"; import CONFIG from "../docs/docs.json"; +// @deprecated, use calculateFileUrl instead export function generateUrl(filename: string, config: PathConfig) { const lang = config.locale === Locale.en ? "" : `/${config.locale}`; @@ -27,14 +28,38 @@ 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 const getSharedNamespace = (slug: string) => { + const [locale, repo, branch, folder, ...rest] = slug.split("/") as [ + Locale, + Repo, + string, + string, + ...string[] + ]; + if ( + repo === Repo.tidb && + branch === CONFIG.docs.tidb.stable && + !!folder && + rest.length > 0 + ) { + if (folder === "develop") { + return "develop"; + } else if (folder === "best-practice") { + return "best-practice"; + } else if (folder === "api") { + return "api"; + } else if (folder === "releases") { + return "tidb-releases"; + } + } + + return ""; +}; + +export function generateNav(config: PathConfig, postSlug: string) { + return `${config.locale}/${config.repo}/${config.branch}/TOC${ + postSlug ? `-${postSlug}` : "" + }`; } export function generateConfig(slug: string): { diff --git a/gatsby/plugin/content/index.ts b/gatsby/plugin/content/index.ts index fc37babf..6750ba89 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 d7361f7f..29ee4538 100644 --- a/gatsby/toc-filter.ts +++ b/gatsby/toc-filter.ts @@ -78,7 +78,7 @@ export async function getFilesFromTocs( filteredTocNodes.forEach((node: TocQueryData["allMdx"]["nodes"][0]) => { const { config } = generateConfig(node.slug); - const toc = mdxAstToToc(node.mdxAST.children, config); + const toc = mdxAstToToc(node.mdxAST.children, 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 96cde833..fef4f3d3 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 00000000..1fe9b946 --- /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 00000000..07fdc559 --- /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 00000000..adc63ed9 --- /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 00000000..8151abac --- /dev/null +++ b/gatsby/url-resolver/__tests__/url-resolver.test.ts @@ -0,0 +1,556 @@ +/** + * 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", + aliases: { + "branch-alias": { + context: { + repo: ["tidb", "tidb-in-kubernetes"], + }, + mappings: { + master: "stable", + main: "stable", + "release-*": "v*", + }, + }, + }, + }; + + 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/master/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/master/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 -> stable)", () => { + const absolutePath = path.join( + sourceBasePath, + "en/tidb/master/alert-rules.md" + ); + const url = calculateFileUrlWithConfig(absolutePath, testConfig); + expect(url).toBe("/en/tidb/stable/alert-rules/"); + }); + + it("should resolve tidb with branch alias (release-8.5 -> v8.5)", () => { + const absolutePath = path.join( + sourceBasePath, + "en/tidb/release-8.5/alert-rules.md" + ); + const url = calculateFileUrlWithConfig(absolutePath, testConfig); + expect(url).toBe("/en/tidb/v8.5/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/stable"); + }); + + 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/stable/page/"); + }); + + it("should resolve api folder", () => { + const absolutePath = path.join( + sourceBasePath, + "en/tidb/master/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/master/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/master/releases/v8.5.md" + ); + const url = calculateFileUrlWithConfig(absolutePath, testConfig); + expect(url).toBe("/en/releases/v8.5/"); + }); + + 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(); + }); +}); + +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 + ); + expect(url).toBe("/tidb/stable/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/master/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); + expect(url).toBe("/zh/tidb/stable/alert-rules"); + }); + + it("should keep /ja/ prefix for Japanese files", () => { + const absolutePath = path.join( + sourceBasePath, + "ja/tidb/master/alert-rules.md" + ); + const url = calculateFileUrlWithConfig(absolutePath, configWithDefaultLang); + expect(url).toBe("/ja/tidb/stable/alert-rules"); + }); + + it("should omit /en/ prefix for English api files", () => { + const absolutePath = path.join( + sourceBasePath, + "en/tidb/master/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 + ); + expect(url).toBe("/tidb/v8.5/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}/{filename}", + conditions: { + repo: ["tidb", "tidb-in-kubernetes"], + }, + filenameTransform: { + ignoreIf: ["_index", "_docHome"], + }, + }, + // Fallback + { + sourcePattern: "/{lang}/{repo}/{...any}/{filename}", + targetPattern: "/{lang}/{repo}/{filename}", + filenameTransform: { + ignoreIf: ["_index", "_docHome"], + }, + }, + ], + aliases: { + "branch-alias": { + context: { + repo: ["tidb", "tidb-in-kubernetes"], + }, + mappings: { + master: "stable", + "release-*": "v*", + }, + }, + }, + }; + + it("should resolve slug format for tidb files", () => { + const slug = "en/tidb/master/alert-rules"; + const url = calculateFileUrlWithConfig(slug, configWithDefaultLang, true); + expect(url).toBe("/tidb/stable/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); + expect(url).toBe("/tidb/stable/alert-rules"); + }); + + it("should resolve slug format for Chinese files", () => { + const slug = "zh/tidb/master/alert-rules"; + const url = calculateFileUrlWithConfig(slug, configWithDefaultLang); + expect(url).toBe("/zh/tidb/stable/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); + expect(url).toBe("/en/tidb/stable/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 + ); + expect(url).toBe("/en/tidb/stable/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 + ); + expect(url).toBe("/tidb/stable/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 + ); + expect(url).toBe("/zh/tidb/stable/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 + ); + expect(url).toBe("/zh/tidb/stable/alert-rules"); + }); +}); diff --git a/gatsby/url-resolver/branch-alias.ts b/gatsby/url-resolver/branch-alias.ts new file mode 100644 index 00000000..8762735a --- /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 00000000..7598ddc8 --- /dev/null +++ b/gatsby/url-resolver/config.ts @@ -0,0 +1,114 @@ +/** + * 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 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}/{repo}/${CONFIG.docs.tidb.stable}/{folder}/{...folders}/{filename}`, + targetPattern: "/{lang}/{folder}/{filename}", + conditions: { + repo: ["tidb"], + folder: ["develop", "best-practice", "api", "releases"], + }, + 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}/{repo}/{branch}/{...folders}/{filename}", + targetPattern: "/{lang}/{repo}/{branch:branch-alias}/{filename}", + conditions: { + repo: ["tidb", "tidb-in-kubernetes"], + }, + 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: used in {branch:branch-alias} + // Supports context-based alias selection + "branch-alias": { + // Context: only apply this alias when repo is tidb or tidb-in-kubernetes + context: { + repo: ["tidb", "tidb-in-kubernetes"], + }, + mappings: { + // Exact matches (repo-specific) + master: "stable", // for tidb + main: "stable", // for tidb-in-kubernetes + // 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*", + // You can also use regex pattern with object syntax: + // { + // pattern: "release-(.*)", + // replacement: "v$1", + // useRegex: true + // } + }, + }, + // Example: repo alias (if needed in the future) + // "repo-alias": { + // mappings: { + // "tidbcloud": "tidb-cloud", + // }, + // }, + }, +}; diff --git a/gatsby/url-resolver/index.ts b/gatsby/url-resolver/index.ts new file mode 100644 index 00000000..a7b8ca39 --- /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 } 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 00000000..aa0c0f23 --- /dev/null +++ b/gatsby/url-resolver/pattern-matcher.ts @@ -0,0 +1,203 @@ +/** + * Pattern matching utilities for URL resolver + */ + +import { getAlias } from "./branch-alias"; + +/** + * 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 { + const patternParts = pattern + .split("/") + .filter((p) => p.length > 0) + .filter((p) => !p.startsWith("/")); + + 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 { + const parts = pattern + .split("/") + .filter((p) => p.length > 0) + .filter((p) => !p.startsWith("/")); + + 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("/"); +} diff --git a/gatsby/url-resolver/types.ts b/gatsby/url-resolver/types.ts new file mode 100644 index 00000000..aeba088e --- /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 00000000..ee8a54e6 --- /dev/null +++ b/gatsby/url-resolver/url-resolver.ts @@ -0,0 +1,269 @@ +/** + * URL resolver for mapping source file paths to published URLs + */ + +import type { + PathMappingRule, + UrlResolverConfig, + ParsedSourcePath, +} from "./types"; +import { matchPattern, applyPattern } from "./pattern-matcher"; +import { defaultUrlResolverConfig } from "./config"; + +/** + * 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 { + // 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; + + return { + segments, + filename, + }; +} + +/** + * 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 { + const parsed = parseSourcePath(absolutePath, config.sourceBasePath); + if (!parsed) { + 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 + + return url; + } + + 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 + ); +} diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..9218ec60 --- /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 ae5d972d..e20facaa 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" }, @@ -109,7 +112,7 @@ "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/Layout/Header/HeaderNav.tsx b/src/components/Layout/Header/HeaderNav.tsx index a0185e54..8295cef6 100644 --- a/src/components/Layout/Header/HeaderNav.tsx +++ b/src/components/Layout/Header/HeaderNav.tsx @@ -14,7 +14,11 @@ import { BuildType } 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 "./HeaderNavConfig"; +import { + NavConfig, + NavGroupConfig, + NavItemConfig, +} from "./HeaderNavConfigType"; import { generateNavConfig } from "./HeaderNavConfigData"; import { clearAllNavStates } from "../LeftNav/LeftNavTree"; @@ -485,6 +489,7 @@ const NavButton = (props: { borderBottom: isSelectedState ? `4px solid ${theme.palette.primary.main}` : ``, + fontWeight: isSelectedState ? 700 : 400, }} > {startIcon} @@ -528,7 +533,7 @@ const NavButton = (props: { alignItems: "center", gap: 0.5, fontSize: 14, - fontWeight: hasSelectedChild ? 700 : 400, + fontWeight: isSelectedState ? 700 : 400, color: theme.palette.carbon[900], }} > diff --git a/src/components/Layout/Header/HeaderNavConfigData.tsx b/src/components/Layout/Header/HeaderNavConfigData.tsx index dfe57ae8..7e0832ea 100644 --- a/src/components/Layout/Header/HeaderNavConfigData.tsx +++ b/src/components/Layout/Header/HeaderNavConfigData.tsx @@ -1,4 +1,4 @@ -import { NavConfig } from "./HeaderNavConfig"; +import { NavConfig } from "./HeaderNavConfigType"; import { PageType } from "shared/usePageType"; import { CLOUD_MODE_KEY } from "shared/useCloudPlan"; import { CloudPlan } from "shared/interface"; @@ -45,19 +45,19 @@ const getDefaultNavConfig = (cloudPlan: CloudPlan | null): NavConfig[] => [ } }, }, - { - type: "item", - label: "TiDB Cloud Premium", - to: `/tidbcloud/premium?${CLOUD_MODE_KEY}=premium`, - selected: (pageType) => - pageType === PageType.TiDBCloud && - cloudPlan === CloudPlan.Premium, - onClick: () => { - if (typeof window !== "undefined") { - sessionStorage.setItem(CLOUD_MODE_KEY, "premium"); - } - }, - }, + // { + // type: "item", + // label: "TiDB Cloud Premium", + // to: `/tidbcloud/premium?${CLOUD_MODE_KEY}=premium`, + // selected: (pageType) => + // pageType === PageType.TiDBCloud && + // cloudPlan === CloudPlan.Premium, + // onClick: () => { + // if (typeof window !== "undefined") { + // sessionStorage.setItem(CLOUD_MODE_KEY, "premium"); + // } + // }, + // }, { type: "item", label: "TiDB Cloud Dedicated", @@ -100,8 +100,8 @@ const getDefaultNavConfig = (cloudPlan: CloudPlan | null): NavConfig[] => [ { type: "item", label: "Developer", - to: "/developer", - selected: (pageType) => pageType === PageType.Developer, + to: "/develop", + selected: (pageType) => pageType === PageType.Develop, }, { type: "item", @@ -123,25 +123,25 @@ const getDefaultNavConfig = (cloudPlan: CloudPlan | null): NavConfig[] => [ type: "item", label: "TiDB Cloud Releases", to: "/release/tidbcloud", - selected: (pageType) => pageType === PageType.Release, + selected: (pageType) => pageType === PageType.Releases, }, { type: "item", label: "TiDB Self-Managed Releases", - to: "/release/tidb", - selected: (pageType) => pageType === PageType.Release, + to: "/releases", + selected: (pageType) => pageType === PageType.Releases, }, { type: "item", label: "TiDB Operator Releases", to: "/release/tidb-in-kubernetes", - selected: (pageType) => pageType === PageType.Release, + selected: (pageType) => pageType === PageType.Releases, }, { type: "item", label: "TiUP Releases", to: "/release/tiup", - selected: (pageType) => pageType === PageType.Release, + selected: (pageType) => pageType === PageType.Releases, }, ], }, diff --git a/src/components/Layout/Header/HeaderNavConfig.ts b/src/components/Layout/Header/HeaderNavConfigType.ts similarity index 100% rename from src/components/Layout/Header/HeaderNavConfig.ts rename to src/components/Layout/Header/HeaderNavConfigType.ts diff --git a/src/components/Layout/Header/index.tsx b/src/components/Layout/Header/index.tsx index 97d539e9..bba0cfad 100644 --- a/src/components/Layout/Header/index.tsx +++ b/src/components/Layout/Header/index.tsx @@ -21,7 +21,7 @@ import { useIsAutoTranslation } from "shared/useIsAutoTranslation"; import { ErrorOutlineOutlined } from "@mui/icons-material"; import { getHeaderHeight, HEADER_HEIGHT } from "shared/headerHeight"; -import { NavItemConfig } from "./HeaderNavConfig"; +import { NavItemConfig } from "./HeaderNavConfigType"; interface HeaderProps { bannerEnabled?: boolean; diff --git a/src/components/Layout/LeftNav/LeftNav.tsx b/src/components/Layout/LeftNav/LeftNav.tsx index 627a0cfc..266fe33a 100644 --- a/src/components/Layout/LeftNav/LeftNav.tsx +++ b/src/components/Layout/LeftNav/LeftNav.tsx @@ -11,7 +11,7 @@ import IconButton from "@mui/material/IconButton"; import MenuIcon from "@mui/icons-material/Menu"; import { RepoNav, PathConfig, BuildType } from "shared/interface"; -import { NavItemConfig } from "../Header/HeaderNavConfig"; +import { NavItemConfig } from "../Header/HeaderNavConfigType"; import LinkComponent from "components/Link"; import LeftNavTree, { clearAllNavStates } from "./LeftNavTree"; import VersionSelect, { @@ -20,6 +20,7 @@ import VersionSelect, { import { getHeaderHeight } from "shared/headerHeight"; import TiDBLogoWithoutText from "media/logo/tidb-logo.svg"; +import { PageType, usePageType } from "shared/usePageType"; interface LeftNavProps { data: RepoNav; @@ -31,6 +32,7 @@ interface LeftNavProps { bannerEnabled?: boolean; availablePlans: string[]; selectedNavItem?: NavItemConfig | null; + language?: string; } export function LeftNavDesktop(props: LeftNavProps) { @@ -42,8 +44,10 @@ export function LeftNavDesktop(props: LeftNavProps) { availIn, buildType, selectedNavItem, + language, } = props; const theme = useTheme(); + const pageType = usePageType(language, current); return ( )} - {pathConfig.repo === "tidb" && ( + {pageType === PageType.TiDB && ( 0 - ? expandedIds[expandedIds.length - 1] - : undefined); + selectedId = storedId || undefined; // Disable transition animation only when restoring saved state and URL changed if (isUrlChanged) { @@ -224,11 +220,7 @@ export default function ControlledTreeView(props: { } else { // Fallback to calculating from current URL expandedIds = calcExpandedIds(data, currentUrl, storedId || undefined); - selectedId = - storedId || - (expandedIds.length > 0 - ? expandedIds[expandedIds.length - 1] - : undefined); + selectedId = storedId || undefined; } setExpanded(expandedIds); diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index 7d5bc9b1..e9b00076 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -9,7 +9,7 @@ import theme from "theme/index"; import Header from "components/Layout/Header"; import Footer from "components/Layout/Footer"; import { Locale, BuildType, PathConfig } from "shared/interface"; -import { NavItemConfig } from "components/Layout/Header/HeaderNavConfig"; +import { NavItemConfig } from "components/Layout/Header/HeaderNavConfigType"; export default function Layout(props: { children?: React.ReactNode; diff --git a/src/components/MDXComponents/EmailSubscriptionForm.tsx b/src/components/MDXComponents/EmailSubscriptionForm.tsx index 697654e7..aeea524e 100644 --- a/src/components/MDXComponents/EmailSubscriptionForm.tsx +++ b/src/components/MDXComponents/EmailSubscriptionForm.tsx @@ -104,6 +104,7 @@ function EmailSubscriptionForm() { { // Check page types in priority order // Check for release pages - if (matchesPageType(segment, PageType.Release, pageUrl)) { - return PageType.Release; + if (matchesPageType(segment, PageType.Releases, pageUrl)) { + return PageType.Releases; } // Check for api pages @@ -98,8 +98,8 @@ export const usePageType = (language?: string, pageUrl?: string): PageType => { } // Check for developer pages - if (matchesPageType(segment, PageType.Developer, pageUrl)) { - return PageType.Developer; + if (matchesPageType(segment, PageType.Develop, pageUrl)) { + return PageType.Develop; } // Check for best-practice pages diff --git a/src/templates/DocTemplate.tsx b/src/templates/DocTemplate.tsx index 56219678..c9eaf6e8 100644 --- a/src/templates/DocTemplate.tsx +++ b/src/templates/DocTemplate.tsx @@ -26,7 +26,7 @@ import { import Seo from "components/Seo"; import { getStable, generateUrl } from "shared/utils"; import { usePageType } from "shared/usePageType"; -import { NavItemConfig } from "components/Layout/Header/HeaderNavConfig"; +import { NavItemConfig } from "components/Layout/Header/HeaderNavConfigType"; import GitCommitInfoCard from "components/Card/GitCommitInfoCard"; import { FeedbackSection } from "components/Card/FeedbackSection"; import { FeedbackSurveyCampaign } from "components/Campaign/FeedbackSurvey"; @@ -161,9 +161,8 @@ function DocTemplate({ const bannerVisible = feature?.banner; const isGlobalHome = !!feature?.globalHome; - const [selectedNavItem, setSelectedNavItem] = React.useState< - NavItemConfig | null - >(null); + const [selectedNavItem, setSelectedNavItem] = + React.useState(null); return ( {!frontmatter?.hide_leftNav && ( Date: Wed, 14 Jan 2026 11:13:24 +0800 Subject: [PATCH 26/37] refactor: remove unused CONFIG import from link resolver configuration - Eliminated the import of CONFIG from docs.json in the link resolver configuration file to improve code cleanliness and maintainability. --- gatsby/link-resolver/config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/gatsby/link-resolver/config.ts b/gatsby/link-resolver/config.ts index 0d2de17b..5936be96 100644 --- a/gatsby/link-resolver/config.ts +++ b/gatsby/link-resolver/config.ts @@ -3,7 +3,6 @@ */ import type { LinkResolverConfig } from "./types"; -import CONFIG from "../../docs/docs.json"; export const defaultLinkResolverConfig: LinkResolverConfig = { // Default language to omit from resolved URLs From 65bacb19152ab297dc972bf51b1b55c937fd7374 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Wed, 14 Jan 2026 12:47:57 +0800 Subject: [PATCH 27/37] feat: introduce TOCNamespace for improved navigation handling - Added TOCNamespace enum to categorize documentation namespaces, enhancing clarity and maintainability. - Refactored navigation logic in various components to utilize the new TOCNamespace for better type safety and consistency. - Updated link resolver and path generation functions to support TOCNamespace, improving URL mapping and navigation accuracy. - Enhanced the layout and selection logic in navigation components to leverage the new namespace structure, ensuring a more intuitive user experience. --- gatsby/create-pages/create-doc-home.ts | 13 +- gatsby/create-pages/create-docs.ts | 25 ++- gatsby/link-resolver/config.ts | 18 +- gatsby/path/getTOCPath.ts | 203 ++++++++++++++++++ gatsby/{path.ts => path/index.ts} | 46 +--- gatsby/url-resolver/config.ts | 19 +- src/components/Layout/Header/HeaderNav.tsx | 53 ++--- .../Layout/Header/HeaderNavConfigData.tsx | 43 ++-- .../Layout/Header/HeaderNavConfigType.ts | 4 +- src/components/Layout/Header/index.tsx | 4 +- src/components/Layout/LeftNav/LeftNav.tsx | 9 +- .../Layout/TitleAction/TitleAction.tsx | 13 +- src/components/Layout/index.tsx | 4 +- src/components/MDXContent.tsx | 11 +- src/shared/interface.ts | 24 +++ src/templates/DocTemplate.tsx | 7 +- 16 files changed, 361 insertions(+), 135 deletions(-) create mode 100644 gatsby/path/getTOCPath.ts rename gatsby/{path.ts => path/index.ts} (77%) diff --git a/gatsby/create-pages/create-doc-home.ts b/gatsby/create-pages/create-doc-home.ts index c7df0527..6a73afbe 100644 --- a/gatsby/create-pages/create-doc-home.ts +++ b/gatsby/create-pages/create-doc-home.ts @@ -3,10 +3,10 @@ import { resolve } from "path"; import type { CreatePagesArgs } from "gatsby"; import sig from "signale"; -import { Locale, BuildType } from "../../src/shared/interface"; +import { Locale, BuildType, TOCNamespace } from "../../src/shared/interface"; import { generateConfig, - generateNav, + generateNavTOCPath, generateDocHomeUrl, } from "../../gatsby/path"; import { DEFAULT_BUILD_TYPE, PageQueryData } from "./interface"; @@ -84,9 +84,12 @@ export const createDocHome = async ({ nodes.forEach((node) => { const { id, name, pathConfig, filePath, slug } = node; const path = generateDocHomeUrl(name, pathConfig); - const navUrl = generateNav(pathConfig, slug); - const starterNavUrl = generateNav(pathConfig, "tidb-cloud-starter"); - const essentialNavUrl = generateNav(pathConfig, "tidb-cloud-essential"); + const navUrl = generateNavTOCPath(pathConfig, slug); + 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] diff --git a/gatsby/create-pages/create-docs.ts b/gatsby/create-pages/create-docs.ts index 83a86e06..b1f91009 100644 --- a/gatsby/create-pages/create-docs.ts +++ b/gatsby/create-pages/create-docs.ts @@ -3,11 +3,17 @@ 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, - generateNav, - getSharedNamespace, + generateNavTOCPath, + getTOCNamespace, } from "../../gatsby/path"; import { calculateFileUrl } from "../../gatsby/url-resolver"; import { cpMarkdown } from "../../gatsby/cp-markdown"; @@ -119,10 +125,14 @@ export const createDocs = async (createPagesArgs: CreatePagesArgs) => { return; } - const namespace = getSharedNamespace(node.slug); - const navUrl = generateNav(pathConfig, namespace); - const starterNavUrl = generateNav(pathConfig, "tidb-cloud-starter"); - const essentialNavUrl = generateNav(pathConfig, "tidb-cloud-essential"); + 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) => @@ -164,6 +174,7 @@ export const createDocs = async (createPagesArgs: CreatePagesArgs) => { feedback: true, }, inDefaultPlan, + namespace, }, }); diff --git a/gatsby/link-resolver/config.ts b/gatsby/link-resolver/config.ts index 5936be96..ce5af72b 100644 --- a/gatsby/link-resolver/config.ts +++ b/gatsby/link-resolver/config.ts @@ -9,6 +9,14 @@ export const defaultLinkResolverConfig: LinkResolverConfig = { defaultLanguage: "en", linkMappings: [ + { + linkPattern: "/releases", + targetPattern: "/releases", + }, + { + linkPattern: "/releases/tidb-cloud", + targetPattern: "/releases/tidb-cloud", + }, // Rule 1: Links starting with specific namespaces (direct link mapping) // /{namespace}/{...any}/{docname} -> /{namespace}/{docname} // Special: tidb-cloud -> tidbcloud @@ -16,13 +24,7 @@ export const defaultLinkResolverConfig: LinkResolverConfig = { linkPattern: "/{namespace}/{...any}/{docname}", targetPattern: "/{namespace}/{docname}", conditions: { - namespace: [ - "tidb-cloud", - "develop", - "best-practice", - "api", - "releases", - ], + namespace: ["tidb-cloud", "develop", "best-practice", "api"], }, namespaceTransform: { "tidb-cloud": "tidbcloud", @@ -42,7 +44,7 @@ export const defaultLinkResolverConfig: LinkResolverConfig = { { pathPattern: `/{lang}/{namespace}/{...any}`, pathConditions: { - namespace: ["develop", "best-practice", "api", "releases"], + namespace: ["develop", "best-practice", "api"], }, linkPattern: "/{...any}/{docname}", targetPattern: "/{lang}/tidb/stable/{docname}", diff --git a/gatsby/path/getTOCPath.ts b/gatsby/path/getTOCPath.ts new file mode 100644 index 00000000..0c8d4708 --- /dev/null +++ b/gatsby/path/getTOCPath.ts @@ -0,0 +1,203 @@ +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.TiDBCloud, + repo: Repo.tidbcloud, + }, + { + namespace: TOCNamespace.TiDBInKubernetes, + repo: Repo.operator, + }, + { + 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.tidb, + branch: CONFIG.docs.tidb.stable, + folder: "tidb-cloud", + restPath: (rest) => rest[0] === "releases", + minRestLength: 1, + }, + { + namespace: TOCNamespace.TiDBInKubernetesReleases, + repo: Repo.operator, + branch: CONFIG.docs.tidb.stable, + folder: "releases", + minRestLength: 1, + }, + { + namespace: TOCNamespace.TiDB, + repo: Repo.tidb, + }, +]; + +/** + * 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 77% rename from gatsby/path.ts rename to gatsby/path/index.ts index d045a617..9fd05739 100644 --- a/gatsby/path.ts +++ b/gatsby/path/index.ts @@ -1,5 +1,13 @@ -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) { @@ -28,40 +36,6 @@ export function generatePdfUrl(config: PathConfig) { }-manual.pdf`; } -export const getSharedNamespace = (slug: string) => { - const [locale, repo, branch, folder, ...rest] = slug.split("/") as [ - Locale, - Repo, - string, - string, - ...string[] - ]; - if ( - repo === Repo.tidb && - branch === CONFIG.docs.tidb.stable && - !!folder && - rest.length > 0 - ) { - if (folder === "develop") { - return "develop"; - } else if (folder === "best-practice") { - return "best-practice"; - } else if (folder === "api") { - return "api"; - } else if (folder === "releases") { - return "tidb-releases"; - } - } - - return ""; -}; - -export function generateNav(config: PathConfig, postSlug: string) { - return `${config.locale}/${config.repo}/${config.branch}/TOC${ - postSlug ? `-${postSlug}` : "" - }`; -} - export function generateConfig(slug: string): { config: PathConfig; filePath: string; diff --git a/gatsby/url-resolver/config.ts b/gatsby/url-resolver/config.ts index 7598ddc8..20efd792 100644 --- a/gatsby/url-resolver/config.ts +++ b/gatsby/url-resolver/config.ts @@ -21,6 +21,20 @@ export const defaultUrlResolverConfig: UrlResolverConfig = { 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", + 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}/ @@ -40,11 +54,10 @@ export const defaultUrlResolverConfig: UrlResolverConfig = { // 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}/{repo}/${CONFIG.docs.tidb.stable}/{folder}/{...folders}/{filename}`, + sourcePattern: `/{lang}/tidb/${CONFIG.docs.tidb.stable}/{folder}/{...folders}/{filename}`, targetPattern: "/{lang}/{folder}/{filename}", conditions: { - repo: ["tidb"], - folder: ["develop", "best-practice", "api", "releases"], + folder: ["develop", "best-practice", "api"], }, filenameTransform: { ignoreIf: ["_index"], diff --git a/src/components/Layout/Header/HeaderNav.tsx b/src/components/Layout/Header/HeaderNav.tsx index 8295cef6..4e61cb39 100644 --- a/src/components/Layout/Header/HeaderNav.tsx +++ b/src/components/Layout/Header/HeaderNav.tsx @@ -9,8 +9,7 @@ import Popover from "@mui/material/Popover"; import Divider from "@mui/material/Divider"; import LinkComponent from "components/Link"; -import { usePageType, PageType } from "shared/usePageType"; -import { BuildType } from "shared/interface"; +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"; @@ -22,38 +21,22 @@ import { import { generateNavConfig } from "./HeaderNavConfigData"; import { clearAllNavStates } from "../LeftNav/LeftNavTree"; -// `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(() => - usePageType(language, pageUrl) - ); - - // update in client side - React.useEffect(() => { - setSelectedItem(usePageType(language, window.location.pathname)); - }, [language]); - - return selectedItem; -}; - // Helper function to find selected item recursively const findSelectedItem = ( configs: NavConfig[], - selectedItem: PageType + namespace?: TOCNamespace ): NavItemConfig | null => { for (const config of configs) { if (config.type === "item") { const isSelected = typeof config.selected === "function" - ? config.selected(selectedItem) + ? config.selected(namespace) : config.selected ?? false; if (isSelected) { return config; } } else if (config.type === "group" && config.children) { - const item = findSelectedItem(config.children, selectedItem); + const item = findSelectedItem(config.children, namespace); if (item) { return item; } @@ -66,10 +49,10 @@ export default function HeaderNavStack(props: { buildType?: BuildType; pageUrl?: string; config?: NavConfig[]; + namespace?: TOCNamespace; onSelectedNavItemChange?: (item: NavItemConfig | null) => void; }) { const { language, t } = useI18next(); - const selectedItem = useSelectedNavItem(language, props.pageUrl); const { cloudPlan } = useCloudPlan(); // Default configuration (backward compatible) @@ -84,10 +67,10 @@ export default function HeaderNavStack(props: { // Find and notify selected item React.useEffect(() => { if (props.onSelectedNavItemChange) { - const selectedNavItem = findSelectedItem(defaultConfig, selectedItem); + const selectedNavItem = findSelectedItem(defaultConfig, props.namespace); props.onSelectedNavItemChange(selectedNavItem); } - }, [defaultConfig, selectedItem, props.onSelectedNavItemChange]); + }, [defaultConfig, props.namespace, props.onSelectedNavItemChange]); return ( ); })} @@ -123,8 +106,8 @@ export default function HeaderNavStack(props: { ); } -const NavGroup = (props: { config: NavConfig; selectedItem: PageType }) => { - const { config, selectedItem } = props; +const NavGroup = (props: { config: NavConfig; namespace?: TOCNamespace }) => { + const { config } = props; const theme = useTheme(); const [anchorEl, setAnchorEl] = React.useState(null); const open = Boolean(anchorEl); @@ -173,7 +156,7 @@ const NavGroup = (props: { config: NavConfig; selectedItem: PageType }) => { // Check if this item/group is selected const isSelected: boolean = isItem && typeof config.selected === "function" - ? config.selected(selectedItem) + ? config.selected(props.namespace) : isItem ? ((config.selected ?? false) as boolean) : false; @@ -185,7 +168,7 @@ const NavGroup = (props: { config: NavConfig; selectedItem: PageType }) => { if (child.type === "item") { const childSelected = typeof child.selected === "function" - ? child.selected(selectedItem) + ? child.selected(props.namespace) : child.selected ?? false; return childSelected; } else { @@ -194,7 +177,7 @@ const NavGroup = (props: { config: NavConfig; selectedItem: PageType }) => { if (nestedChild.type === "item") { const nestedSelected = typeof nestedChild.selected === "function" - ? nestedChild.selected(selectedItem) + ? nestedChild.selected(props.namespace) : nestedChild.selected ?? false; return nestedSelected; } @@ -279,7 +262,7 @@ const NavGroup = (props: { config: NavConfig; selectedItem: PageType }) => { key={`${index}-${nestedIndex}`} item={nestedChild} groupTitle={child.title} - selectedItem={selectedItem} + namespace={props.namespace} onClose={handlePopoverClose} /> ); @@ -312,7 +295,7 @@ const NavGroup = (props: { config: NavConfig; selectedItem: PageType }) => { ))} @@ -362,13 +345,13 @@ const GroupTitle = (props: { const NavMenuItem = (props: { item: NavItemConfig; groupTitle?: string | React.ReactNode; - selectedItem: PageType; + namespace?: TOCNamespace; onClose: () => void; }) => { - const { item, groupTitle, selectedItem, onClose } = props; + const { item, groupTitle, namespace, onClose } = props; const isSelected = typeof item.selected === "function" - ? item.selected(selectedItem) + ? item.selected(namespace) : item.selected ?? false; return ( diff --git a/src/components/Layout/Header/HeaderNavConfigData.tsx b/src/components/Layout/Header/HeaderNavConfigData.tsx index 7e0832ea..e440f015 100644 --- a/src/components/Layout/Header/HeaderNavConfigData.tsx +++ b/src/components/Layout/Header/HeaderNavConfigData.tsx @@ -1,7 +1,6 @@ import { NavConfig } from "./HeaderNavConfigType"; -import { PageType } from "shared/usePageType"; import { CLOUD_MODE_KEY } from "shared/useCloudPlan"; -import { CloudPlan } from "shared/interface"; +import { CloudPlan, TOCNamespace } from "shared/interface"; import TiDBCloudIcon from "media/icons/cloud-03.svg"; import TiDBIcon from "media/icons/layers-three-01.svg"; @@ -23,8 +22,8 @@ const getDefaultNavConfig = (cloudPlan: CloudPlan | null): NavConfig[] => [ type: "item", label: "TiDB Cloud Starter", to: `/tidbcloud/starter?${CLOUD_MODE_KEY}=starter`, - selected: (pageType) => - pageType === PageType.TiDBCloud && + selected: (namespace) => + namespace === TOCNamespace.TiDBCloud && cloudPlan === CloudPlan.Starter, onClick: () => { if (typeof window !== "undefined") { @@ -36,8 +35,8 @@ const getDefaultNavConfig = (cloudPlan: CloudPlan | null): NavConfig[] => [ type: "item", label: "TiDB Cloud Essential", to: `/tidbcloud/essential?${CLOUD_MODE_KEY}=essential`, - selected: (pageType) => - pageType === PageType.TiDBCloud && + selected: (namespace) => + namespace === TOCNamespace.TiDBCloud && cloudPlan === CloudPlan.Essential, onClick: () => { if (typeof window !== "undefined") { @@ -49,8 +48,8 @@ const getDefaultNavConfig = (cloudPlan: CloudPlan | null): NavConfig[] => [ // type: "item", // label: "TiDB Cloud Premium", // to: `/tidbcloud/premium?${CLOUD_MODE_KEY}=premium`, - // selected: (pageType) => - // pageType === PageType.TiDBCloud && + // selected: (namespace) => + // namespace === TOCNamespace.TiDBCloud && // cloudPlan === CloudPlan.Premium, // onClick: () => { // if (typeof window !== "undefined") { @@ -65,8 +64,8 @@ const getDefaultNavConfig = (cloudPlan: CloudPlan | null): NavConfig[] => [ cloudPlan === "dedicated" || !cloudPlan ? `/tidbcloud` : `/tidbcloud?${CLOUD_MODE_KEY}=dedicated`, - selected: (pageType) => - pageType === PageType.TiDBCloud && + selected: (namespace) => + namespace === TOCNamespace.TiDBCloud && cloudPlan === CloudPlan.Dedicated, onClick: () => { if (typeof window !== "undefined") { @@ -85,13 +84,14 @@ const getDefaultNavConfig = (cloudPlan: CloudPlan | null): NavConfig[] => [ type: "item", label: "TiDB Self-Managed", to: "/tidb/stable", - selected: (pageType) => pageType === PageType.TiDB, + selected: (namespace) => namespace === TOCNamespace.TiDB, }, { type: "item", label: "TiDB Self-Managed on Kubernetes", to: "/tidb-in-kubernetes/stable", - selected: (pageType) => pageType === PageType.TiDBInKubernetes, + selected: (namespace) => + namespace === TOCNamespace.TiDBInKubernetes, }, ], }, @@ -101,19 +101,19 @@ const getDefaultNavConfig = (cloudPlan: CloudPlan | null): NavConfig[] => [ type: "item", label: "Developer", to: "/develop", - selected: (pageType) => pageType === PageType.Develop, + selected: (namespace) => namespace === TOCNamespace.Develop, }, { type: "item", label: "Best Practices", to: "/best-practice", - selected: (pageType) => pageType === PageType.BestPractice, + selected: (namespace) => namespace === TOCNamespace.BestPractice, }, { type: "item", label: "API", to: "/api", - selected: (pageType) => pageType === PageType.Api, + selected: (namespace) => namespace === TOCNamespace.API, }, { type: "group", @@ -123,25 +123,26 @@ const getDefaultNavConfig = (cloudPlan: CloudPlan | null): NavConfig[] => [ type: "item", label: "TiDB Cloud Releases", to: "/release/tidbcloud", - selected: (pageType) => pageType === PageType.Releases, + selected: (namespace) => namespace === TOCNamespace.TidbCloudReleases, }, { type: "item", label: "TiDB Self-Managed Releases", to: "/releases", - selected: (pageType) => pageType === PageType.Releases, + selected: (namespace) => namespace === TOCNamespace.TiDBReleases, }, { type: "item", label: "TiDB Operator Releases", to: "/release/tidb-in-kubernetes", - selected: (pageType) => pageType === PageType.Releases, + selected: (namespace) => + namespace === TOCNamespace.TiDBInKubernetesReleases, }, { type: "item", label: "TiUP Releases", - to: "/release/tiup", - selected: (pageType) => pageType === PageType.Releases, + to: "https://github.com/pingcap/tiup/releases", + selected: () => false, }, ], }, @@ -155,7 +156,7 @@ const archiveNavConfig: NavConfig[] = [ type: "item", label: "TiDB Self-Managed", to: "/tidb/v2.1", - selected: (pageType) => pageType === PageType.TiDB, + selected: (namespace) => namespace === TOCNamespace.TiDB, }, ]; diff --git a/src/components/Layout/Header/HeaderNavConfigType.ts b/src/components/Layout/Header/HeaderNavConfigType.ts index 26bf86f4..668bc933 100644 --- a/src/components/Layout/Header/HeaderNavConfigType.ts +++ b/src/components/Layout/Header/HeaderNavConfigType.ts @@ -1,5 +1,5 @@ import { ReactNode } from "react"; -import { PageType } from "shared/usePageType"; +import { TOCNamespace } from "shared/interface"; /** * Single navigation item configuration @@ -15,7 +15,7 @@ export interface NavItemConfig { /** Optional alt text for GTM tracking */ alt?: string; /** Whether this item is selected (can be a function that returns boolean) */ - selected?: boolean | ((pageType: PageType) => boolean); + selected?: boolean | ((namespace?: TOCNamespace) => boolean); /** Optional click handler */ onClick?: () => void; /** Whether to use i18n for the link */ diff --git a/src/components/Layout/Header/index.tsx b/src/components/Layout/Header/index.tsx index bba0cfad..6bdbfa83 100644 --- a/src/components/Layout/Header/index.tsx +++ b/src/components/Layout/Header/index.tsx @@ -12,7 +12,7 @@ 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 "components/Layout/Banner"; import { generateDocsHomeUrl, generateUrl } from "shared/utils"; @@ -32,6 +32,7 @@ interface HeaderProps { pageUrl?: string; name?: string; pathConfig?: PathConfig; + namespace?: TOCNamespace; onSelectedNavItemChange?: (item: NavItemConfig | null) => void; } @@ -112,6 +113,7 @@ export default function Header(props: HeaderProps) { diff --git a/src/components/Layout/LeftNav/LeftNav.tsx b/src/components/Layout/LeftNav/LeftNav.tsx index 266fe33a..58498431 100644 --- a/src/components/Layout/LeftNav/LeftNav.tsx +++ b/src/components/Layout/LeftNav/LeftNav.tsx @@ -10,7 +10,7 @@ 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, { clearAllNavStates } from "./LeftNavTree"; @@ -20,7 +20,6 @@ import VersionSelect, { import { getHeaderHeight } from "shared/headerHeight"; import TiDBLogoWithoutText from "media/logo/tidb-logo.svg"; -import { PageType, usePageType } from "shared/usePageType"; interface LeftNavProps { data: RepoNav; @@ -33,6 +32,7 @@ interface LeftNavProps { availablePlans: string[]; selectedNavItem?: NavItemConfig | null; language?: string; + namespace?: TOCNamespace; } export function LeftNavDesktop(props: LeftNavProps) { @@ -44,10 +44,9 @@ export function LeftNavDesktop(props: LeftNavProps) { availIn, buildType, selectedNavItem, - language, + namespace, } = props; const theme = useTheme(); - const pageType = usePageType(language, current); return ( )} - {pageType === PageType.TiDB && ( + {namespace === TOCNamespace.TiDB && ( { - 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( - () => usePageType(language, pageUrl), - [pageUrl] - ); const contributeOpen = Boolean(contributeAnchorEl); @@ -249,7 +246,7 @@ export const TitleAction = (props: TitleActionProps) => { )} {/* Download PDF */} - {pageType === PageType.TiDB && language !== "ja" && ( + {namespace === TOCNamespace.TiDB && language !== "ja" && (
+ + ); +} - {props.buildType !== "archive" && ( - - - gtmTrack(GTMEvent.ClickHeadNav, { - item_name: t("navbar.cloud"), - }) - } - > - - - - - - )} +// Recursive component to render nav config (item or group) +const RenderNavConfig = (props: { + config: NavConfig; + namespace?: TOCNamespace; + onClose: () => void; +}) => { + const { config, namespace, onClose } = props; - - - gtmTrack(GTMEvent.ClickHeadNav, { - item_name: t("navbar.tidb"), - }) - } + 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 && ( + - - - - - - - {/* {language === "zh" && ( - - - gtmTrack(GTMEvent.ClickHeadNav, { - item_name: t("navbar.download"), - }) - } + {config.titleIcon && ( + + {config.titleIcon} + + )} + - - - - - + {config.title} + + )} - {["ja", "en"].includes(language) && ( - - - gtmTrack(GTMEvent.ClickHeadNav, { - item_name: t("navbar.learningCenter"), - }) - } - > - - - - - - )} + {/* 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) => ( + + ))} + + ); + })} - {["zh"].includes(language) && ( - - - gtmTrack(GTMEvent.ClickHeadNav, { - item_name: t("navbar.asktug"), - }) - } + {/* 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/index.tsx b/src/components/Layout/Header/index.tsx index 6bdbfa83..25e59632 100644 --- a/src/components/Layout/Header/index.tsx +++ b/src/components/Layout/Header/index.tsx @@ -58,7 +58,10 @@ export default function Header(props: HeaderProps) { height: "100%", paddingLeft: "24px", paddingRight: "24px", - flexDirection: "column", + flexDirection: { + xs: "column-reverse", + md: "column", + }, alignItems: "stretch", borderBottom: `1px solid ${theme.palette.carbon[400]}`, }} @@ -116,7 +119,10 @@ export default function Header(props: HeaderProps) { namespace={props.namespace} onSelectedNavItemChange={props.onSelectedNavItemChange} /> - + {props.locales.length > 0 && ( diff --git a/src/components/Layout/RightNav/RightNav.bk.tsx b/src/components/Layout/RightNav/RightNav.bk.tsx deleted file mode 100644 index 0ee6bae9..00000000 --- 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 SimCardDownloadIcon from "@mui/icons-material/SimCardDownload"; -import ChevronDownIcon from "media/icons/chevron-down.svg"; -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 { usePageType } from "shared/usePageType"; - -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( - () => usePageType(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} - - - ); - })} - - - ); -} From a988a4228bcfcb3951da0e3464788e898a0962c4 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Wed, 14 Jan 2026 21:05:57 +0800 Subject: [PATCH 34/37] refactor: replace pageUrl with namespace in layout and header components for improved type safety - Removed pageUrl prop from Layout, Header, and HeaderNav components, replacing it with the required namespace prop. - Updated useIsAutoTranslation to utilize namespace instead of pageUrl for better context handling. - Refactored filterRightToc function to accept namespace, enhancing filtering logic based on TOCNamespace. - Deleted unused usePageType module to streamline the codebase and improve maintainability. --- src/components/Layout/Header/HeaderAction.tsx | 8 +- src/components/Layout/Header/HeaderNav.tsx | 1 - src/components/Layout/Header/index.tsx | 8 +- src/components/Layout/index.tsx | 4 +- src/shared/filterRightToc.ts | 15 +-- src/shared/useIsAutoTranslation.ts | 9 +- src/shared/usePageType.ts | 126 ------------------ src/templates/DocTemplate.tsx | 7 +- 8 files changed, 21 insertions(+), 157 deletions(-) delete mode 100644 src/shared/usePageType.ts diff --git a/src/components/Layout/Header/HeaderAction.tsx b/src/components/Layout/Header/HeaderAction.tsx index 9e1a89bd..c1116be8 100644 --- a/src/components/Layout/Header/HeaderAction.tsx +++ b/src/components/Layout/Header/HeaderAction.tsx @@ -13,7 +13,7 @@ import CloudIcon from "@mui/icons-material/Cloud"; import Search from "components/Search"; -import { Locale, BuildType } from "shared/interface"; +import { Locale, BuildType, TOCNamespace } from "shared/interface"; import { Link } from "gatsby"; import { useIsAutoTranslation } from "shared/useIsAutoTranslation"; @@ -56,12 +56,12 @@ export default function HeaderAction(props: { supportedLocales: Locale[]; docInfo?: { type: string; version: string }; buildType?: BuildType; - pageUrl?: string; + namespace: TOCNamespace; }) { - const { docInfo, buildType, pageUrl } = props; + const { docInfo, buildType, namespace } = props; const { language, t } = useI18next(); const { showTiDBAIButton, initializingTiDBAI } = useTiDBAIStatus(); - const isAutoTranslation = useIsAutoTranslation(pageUrl || ""); + const isAutoTranslation = useIsAutoTranslation(namespace); return ( void; diff --git a/src/components/Layout/Header/index.tsx b/src/components/Layout/Header/index.tsx index 25e59632..34a9c075 100644 --- a/src/components/Layout/Header/index.tsx +++ b/src/components/Layout/Header/index.tsx @@ -29,10 +29,9 @@ interface HeaderProps { locales: Locale[]; docInfo?: { type: string; version: string }; buildType?: BuildType; - pageUrl?: string; name?: string; pathConfig?: PathConfig; - namespace?: TOCNamespace; + namespace: TOCNamespace; onSelectedNavItemChange?: (item: NavItemConfig | null) => void; } @@ -100,7 +99,7 @@ export default function Header(props: HeaderProps) { supportedLocales={props.locales} docInfo={props.docInfo} buildType={props.buildType} - pageUrl={props.pageUrl} + namespace={props.namespace} /> @@ -115,7 +114,6 @@ export default function Header(props: HeaderProps) { > @@ -137,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/index.tsx b/src/components/Layout/index.tsx index ba8df8e7..d4dda764 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -18,10 +18,9 @@ export default function Layout(props: { locales?: Locale[]; docInfo?: { type: string; version: string }; buildType?: BuildType; - pageUrl?: string; name?: string; pathConfig?: PathConfig; - namespace?: TOCNamespace; + namespace: TOCNamespace; onSelectedNavItemChange?: (item: NavItemConfig | null) => void; }) { return ( @@ -33,7 +32,6 @@ export default function Layout(props: { locales={props.locales || []} docInfo={props.docInfo} buildType={props.buildType} - pageUrl={props.pageUrl} name={props.name} pathConfig={props.pathConfig} namespace={props.namespace} diff --git a/src/shared/filterRightToc.ts b/src/shared/filterRightToc.ts index eda18282..f40c1a2e 100644 --- a/src/shared/filterRightToc.ts +++ b/src/shared/filterRightToc.ts @@ -1,13 +1,12 @@ -import { TableOfContent, CloudPlan } from "./interface"; -import { PageType } from "shared/usePageType"; +import { TableOfContent, CloudPlan, TOCNamespace } from "./interface"; /** * Filter right TOC based on CustomContent conditions - * Removes TOC items that don't match current context (pageType, cloudPlan, language) + * Removes TOC items that don't match current context (namespace, cloudPlan, language) */ export function filterRightToc( items: TableOfContent[], - pageType: PageType, + namespace: TOCNamespace, cloudPlan: CloudPlan | null, language: string ): TableOfContent[] { @@ -19,10 +18,10 @@ export function filterRightToc( if (item.condition) { const { platform, plan, language: conditionLang } = item.condition; - // Check platform match + // Check platform match (namespace) if (platform) { - const normalizedPlatform = platform.replace("-", ""); - if (normalizedPlatform !== pageType) { + // platform value should match TOCNamespace enum value + if (platform !== namespace) { return null; // Filter out this item } } @@ -48,7 +47,7 @@ export function filterRightToc( if (item.items && item.items.length > 0) { const filteredItems = filterRightToc( item.items, - pageType, + namespace, cloudPlan, language ); diff --git a/src/shared/useIsAutoTranslation.ts b/src/shared/useIsAutoTranslation.ts index d407b933..9175e34a 100644 --- a/src/shared/useIsAutoTranslation.ts +++ b/src/shared/useIsAutoTranslation.ts @@ -1,12 +1,11 @@ import { useI18next } from "gatsby-plugin-react-i18next"; -import { usePageType, PageType } from "shared/usePageType"; +import { TOCNamespace } from "shared/interface"; -export const useIsAutoTranslation = (pageUrl: string) => { +export const useIsAutoTranslation = (namespace: TOCNamespace) => { const { language } = useI18next(); - const pageType = usePageType(language, pageUrl); const isAutoTranslation = - pageType !== PageType.Home && + namespace !== TOCNamespace.Home && (language === "ja" || - (language === "zh" && pageType === PageType.TiDBCloud)); + (language === "zh" && namespace === TOCNamespace.TiDBCloud)); return isAutoTranslation; }; diff --git a/src/shared/usePageType.ts b/src/shared/usePageType.ts deleted file mode 100644 index bb03e2ac..00000000 --- a/src/shared/usePageType.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { Locale } from "./interface"; - -export enum PageType { - Home = "home", - TiDB = "tidb", - TiDBCloud = "tidbcloud", - TiDBInKubernetes = "tidb-in-kubernetes", - Develop = "develop", - BestPractice = "best-practice", - Api = "api", - Releases = "releases", -} - -const LANGUAGE_CODES = Object.values(Locale); - -/** - * Normalize pageUrl by removing language prefix and extracting the segment to check - * Returns the segment that should be checked for pageType (second segment if language exists, first otherwise) - */ -const normalizePageUrl = (pageUrl: string): string => { - // Remove leading and trailing slashes, then split - const segments = pageUrl - .replace(/^\/+|\/+$/g, "") - .split("/") - .filter(Boolean); - - if (segments.length === 0) { - return ""; - } - - // Check if first segment is a language code - const firstSegment = segments[0]; - if (LANGUAGE_CODES.includes(firstSegment as Locale)) { - // If first segment is language, return second segment (or empty if only language) - return segments.length > 1 ? segments[1] : ""; - } - - // No language prefix, return first segment - return firstSegment; -}; - -/** - * Check if the normalized segment matches a pageType - * Matches if segment equals pageType exactly - * Also checks if the full URL path contains /{pageType} at the end or followed by / - */ -const matchesPageType = ( - segment: string, - pageType: string, - fullUrl: string -): boolean => { - if (!segment) { - return false; - } - - // Exact match of the segment - if (segment === pageType) { - return true; - } - - // Check if URL contains /{pageType} at the end or followed by / - // This handles cases like /tidb/stable/... or /en/tidb/stable/... - const pattern = new RegExp(`/${pageType}(/|$)`); - return pattern.test(fullUrl); -}; - -export const usePageType = (language?: string, pageUrl?: string): PageType => { - if (!pageUrl) { - return PageType.Home; - } - - // Check for home page - if ( - pageUrl === "/" || - pageUrl === `/${language}/` || - pageUrl === `/${language}` - ) { - return PageType.Home; - } - - // Normalize URL to get the segment to check - const segment = normalizePageUrl(pageUrl); - - // If no segment after normalization, it's home - if (!segment) { - return PageType.Home; - } - - // Check page types in priority order - // Check for release pages - if (matchesPageType(segment, PageType.Releases, pageUrl)) { - return PageType.Releases; - } - - // Check for api pages - if (matchesPageType(segment, PageType.Api, pageUrl)) { - return PageType.Api; - } - - // Check for developer pages - if (matchesPageType(segment, PageType.Develop, pageUrl)) { - return PageType.Develop; - } - - // Check for best-practice pages - if (matchesPageType(segment, PageType.BestPractice, pageUrl)) { - return PageType.BestPractice; - } - - // Check for tidb-in-kubernetes pages - if (matchesPageType(segment, PageType.TiDBInKubernetes, pageUrl)) { - return PageType.TiDBInKubernetes; - } - - // Check for tidbcloud pages - if (matchesPageType(segment, PageType.TiDBCloud, pageUrl)) { - return PageType.TiDBCloud; - } - - // Check for tidb pages - if (matchesPageType(segment, PageType.TiDB, pageUrl)) { - return PageType.TiDB; - } - - return PageType.Home; -}; diff --git a/src/templates/DocTemplate.tsx b/src/templates/DocTemplate.tsx index 6ea0f9ff..788901e7 100644 --- a/src/templates/DocTemplate.tsx +++ b/src/templates/DocTemplate.tsx @@ -26,7 +26,6 @@ import { } from "shared/interface"; import Seo from "components/Seo"; import { getStable, generateUrl } from "shared/utils"; -import { usePageType } from "shared/usePageType"; import { NavItemConfig } from "components/Layout/Header/HeaderNavConfigType"; import GitCommitInfoCard from "components/Card/GitCommitInfoCard"; import { FeedbackSection } from "components/Card/FeedbackSection"; @@ -148,7 +147,6 @@ function DocTemplate({ availablePlans.push("essential"); } - const pageType = usePageType(language, pageUrl); const rightTocData: TableOfContent[] | undefined = React.useMemo(() => { let tocItems: TableOfContent[] = []; if (toc?.items?.length === 1) { @@ -158,8 +156,8 @@ function DocTemplate({ } // Filter TOC based on CustomContent conditions - return filterRightToc(tocItems, pageType, cloudPlan, language); - }, [toc, pageType, cloudPlan, language]); + return filterRightToc(tocItems, namespace, cloudPlan, language); + }, [toc, namespace, cloudPlan, language]); const stableBranch = getStable(pathConfig.repo); @@ -175,7 +173,6 @@ function DocTemplate({ pathConfig={pathConfig} locales={availIn.locale} bannerEnabled={bannerVisible} - pageUrl={pageUrl} menu={ frontmatter?.hide_leftNav ? null : ( Date: Wed, 14 Jan 2026 23:13:26 +0800 Subject: [PATCH 35/37] refactor: update link resolver configuration to include language support - Modified target patterns in link-resolver configuration to incorporate language as a dynamic segment in URLs. - Extracted current language from the page URL and added it as a default variable in the link resolution process. - Enhanced link mapping rules to ensure proper handling of language-specific paths for tidb releases and documentation. --- gatsby/link-resolver/config.ts | 8 ++++---- gatsby/link-resolver/link-resolver.ts | 13 +++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/gatsby/link-resolver/config.ts b/gatsby/link-resolver/config.ts index 46b82aba..79519daa 100644 --- a/gatsby/link-resolver/config.ts +++ b/gatsby/link-resolver/config.ts @@ -11,22 +11,22 @@ export const defaultLinkResolverConfig: LinkResolverConfig = { linkMappings: [ { linkPattern: "/releases/tidb", - targetPattern: "/releases/tidb", + targetPattern: "/{curLang}/releases/tidb", }, { linkPattern: "/releases/tidb-cloud", - targetPattern: "/releases/tidb-cloud", + targetPattern: "/{curLang}/releases/tidb-cloud", }, { linkPattern: "/releases/tidb-operator", - targetPattern: "/releases/tidb-operator", + targetPattern: "/{curLang}/releases/tidb-operator", }, // Rule 1: Links starting with specific namespaces (direct link mapping) // /{namespace}/{...any}/{docname} -> /{namespace}/{docname} // Special: tidb-cloud -> tidbcloud { linkPattern: "/{namespace}/{...any}/{docname}", - targetPattern: "/{namespace}/{docname}", + targetPattern: "/{curLang}/{namespace}/{docname}", conditions: { namespace: ["tidb-cloud", "develop", "best-practice", "api"], }, diff --git a/gatsby/link-resolver/link-resolver.ts b/gatsby/link-resolver/link-resolver.ts index 8d2e9b94..c5f5d125 100644 --- a/gatsby/link-resolver/link-resolver.ts +++ b/gatsby/link-resolver/link-resolver.ts @@ -68,6 +68,9 @@ export function resolveMarkdownLink( // 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; @@ -79,6 +82,11 @@ export function resolveMarkdownLink( continue; } + // Add curLang as default variable + if (curLang) { + variables.curLang = curLang; + } + // Check conditions if (rule.conditions) { let conditionsMet = true; @@ -135,6 +143,11 @@ export function resolveMarkdownLink( // 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) { From b352edbba9ebfb0b198cc4ba6bf615e2deb2f21a Mon Sep 17 00:00:00 2001 From: Suhaha Date: Thu, 15 Jan 2026 00:29:02 +0800 Subject: [PATCH 36/37] refactor: enhance URL and link resolver functionality with caching and path calculation improvements - Introduced caching mechanisms in URL and link resolvers to optimize performance by reducing redundant calculations. - Updated `calculateFileUrl` and `parseSourcePath` functions to utilize caching for improved efficiency. - Enhanced link resolution logic to incorporate dynamic language handling and improved path calculations. - Added utility functions to clear caches, facilitating testing and configuration changes. --- gatsby/cloud-plan.ts | 4 +- gatsby/create-types/create-navs.ts | 26 ++- .../__tests__/link-resolver.test.ts | 213 +++++++++++++++++- gatsby/link-resolver/config.ts | 2 +- gatsby/link-resolver/index.ts | 8 +- gatsby/link-resolver/link-resolver.ts | 49 +++- gatsby/toc-filter.ts | 4 +- .../__tests__/url-resolver.test.ts | 118 ++++++---- gatsby/url-resolver/index.ts | 2 +- gatsby/url-resolver/pattern-matcher.ts | 36 ++- gatsby/url-resolver/url-resolver.ts | 54 ++++- 11 files changed, 434 insertions(+), 82 deletions(-) diff --git a/gatsby/cloud-plan.ts b/gatsby/cloud-plan.ts index 4657a5a2..4e519262 100644 --- a/gatsby/cloud-plan.ts +++ b/gatsby/cloud-plan.ts @@ -2,6 +2,7 @@ import { mdxAstToToc, TocQueryData } from "./toc"; import { generateConfig } from "./path"; import { extractFilesFromToc } from "./toc-filter"; 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, node.slug); + 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/create-types/create-navs.ts b/gatsby/create-types/create-navs.ts index 7ef38f8b..5fe431d7 100644 --- a/gatsby/create-types/create-navs.ts +++ b/gatsby/create-types/create-navs.ts @@ -1,7 +1,7 @@ import { CreatePagesArgs } from "gatsby"; -import { generateConfig } from "../path"; import { mdxAstToToc } from "../toc"; import { Root } from "mdast"; +import { calculateFileUrl } from "../url-resolver"; export const createNavs = ({ actions }: CreatePagesArgs) => { const { createTypes, createFieldExtension } = actions; @@ -31,7 +31,13 @@ export const createNavs = ({ actions }: CreatePagesArgs) => { } ); - const res = mdxAstToToc(mdxAST.children, slug, undefined, true); + const tocPath = calculateFileUrl(slug); + const res = mdxAstToToc( + mdxAST.children, + tocPath || slug, + undefined, + true + ); mdxNode.nav = res; return res; }, @@ -66,7 +72,13 @@ export const createNavs = ({ actions }: CreatePagesArgs) => { if (!slug.endsWith("TOC-tidb-cloud-starter")) throw new Error(`unsupported query in ${slug}`); - const res = mdxAstToToc(mdxAST.children, slug, undefined, true); + const tocPath = calculateFileUrl(slug); + const res = mdxAstToToc( + mdxAST.children, + tocPath || slug, + undefined, + true + ); mdxNode.starterNav = res; return res; }, @@ -101,7 +113,13 @@ export const createNavs = ({ actions }: CreatePagesArgs) => { if (!slug.endsWith("TOC-tidb-cloud-essential")) throw new Error(`unsupported query in ${slug}`); - const res = mdxAstToToc(mdxAST.children, slug, undefined, true); + const tocPath = calculateFileUrl(slug); + const res = mdxAstToToc( + mdxAST.children, + tocPath || slug, + undefined, + true + ); mdxNode.essentialNav = res; return res; }, diff --git a/gatsby/link-resolver/__tests__/link-resolver.test.ts b/gatsby/link-resolver/__tests__/link-resolver.test.ts index 001c0487..d5aa9614 100644 --- a/gatsby/link-resolver/__tests__/link-resolver.test.ts +++ b/gatsby/link-resolver/__tests__/link-resolver.test.ts @@ -126,7 +126,7 @@ describe("resolveMarkdownLink", () => { }); describe("linkMappings - namespace rules", () => { - it("should resolve develop namespace links", () => { + 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" @@ -134,7 +134,23 @@ describe("resolveMarkdownLink", () => { expect(result).toBe("/develop/vector-search-data-types"); }); - it("should resolve best-practice namespace links", () => { + 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" @@ -142,7 +158,15 @@ describe("resolveMarkdownLink", () => { expect(result).toBe("/best-practice/query-optimization"); }); - it("should resolve api namespace links", () => { + 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" @@ -150,15 +174,85 @@ describe("resolveMarkdownLink", () => { expect(result).toBe("/api/introduction"); }); - it("should resolve releases namespace links", () => { + 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" ); - expect(result).toBe("/releases/release-notes"); + // /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", () => { + it("should transform tidb-cloud to tidbcloud (en - default language omitted)", () => { const result = resolveMarkdownLink( "/tidb-cloud/dedicated/getting-started", "/en/tidb/stable/alert-rules" @@ -166,6 +260,14 @@ describe("resolveMarkdownLink", () => { 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", @@ -179,13 +281,21 @@ describe("resolveMarkdownLink", () => { expect(result).toBe("/tidb/stable/page"); }); - it("should handle namespace links with multiple path segments", () => { + 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", () => { @@ -298,7 +408,13 @@ describe("resolveMarkdownLink", () => { "/v8.5/release-notes", "/en/releases/v8.5" ); - expect(result).toBe("/tidb/stable/release-notes"); + // 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", () => { @@ -330,7 +446,13 @@ describe("resolveMarkdownLink", () => { "/v8.5/whats-new/features", "/en/releases/v8.5/whats-new" ); - expect(result).toBe("/tidb/stable/features"); + // 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", () => { @@ -407,7 +529,13 @@ describe("resolveMarkdownLink", () => { "/v8.5/changelog/changes", "/en/releases/v8.5/changelog" ); - expect(result).toBe("/tidb/stable/changes"); + // 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", () => { @@ -455,6 +583,71 @@ describe("resolveMarkdownLink", () => { }); }); + 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( diff --git a/gatsby/link-resolver/config.ts b/gatsby/link-resolver/config.ts index 79519daa..6eb78714 100644 --- a/gatsby/link-resolver/config.ts +++ b/gatsby/link-resolver/config.ts @@ -22,7 +22,7 @@ export const defaultLinkResolverConfig: LinkResolverConfig = { targetPattern: "/{curLang}/releases/tidb-operator", }, // Rule 1: Links starting with specific namespaces (direct link mapping) - // /{namespace}/{...any}/{docname} -> /{namespace}/{docname} + // /{namespace}/{...any}/{docname} -> /{curLang}/{namespace}/{docname} // Special: tidb-cloud -> tidbcloud { linkPattern: "/{namespace}/{...any}/{docname}", diff --git a/gatsby/link-resolver/index.ts b/gatsby/link-resolver/index.ts index 0460a041..ffba02ee 100644 --- a/gatsby/link-resolver/index.ts +++ b/gatsby/link-resolver/index.ts @@ -7,14 +7,10 @@ */ // Export types -export type { - LinkMappingRule, - LinkMappingByPath, - LinkResolverConfig, -} from "./types"; +export type { LinkMappingRule, LinkResolverConfig } from "./types"; // Export link resolver functions -export { resolveMarkdownLink } from "./link-resolver"; +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 index c5f5d125..09b51d1a 100644 --- a/gatsby/link-resolver/link-resolver.ts +++ b/gatsby/link-resolver/link-resolver.ts @@ -6,16 +6,35 @@ 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 []; } - return normalized.split("/").filter((s) => s.length > 0); + const segments = normalized.split("/").filter((s) => s.length > 0); + parsedLinkPathCache.set(linkPath, segments); + return segments; } /** @@ -50,18 +69,28 @@ export function resolveMarkdownLink( linkPath: string, currentPageUrl: string ): string | null { - const linkConfig = defaultLinkResolverConfig; - const urlConfig = defaultUrlResolverConfig; + // Early exit for external links and anchor links (most common case) if (!linkPath || linkPath.startsWith("http") || linkPath.startsWith("#")) { - // Skip external links and anchor links 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; } @@ -170,9 +199,21 @@ export function resolveMarkdownLink( 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/toc-filter.ts b/gatsby/toc-filter.ts index 29ee4538..e3c517e9 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, node.slug); + 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/url-resolver/__tests__/url-resolver.test.ts b/gatsby/url-resolver/__tests__/url-resolver.test.ts index 8151abac..b2585598 100644 --- a/gatsby/url-resolver/__tests__/url-resolver.test.ts +++ b/gatsby/url-resolver/__tests__/url-resolver.test.ts @@ -109,18 +109,6 @@ describe("calculateFileUrl", () => { // Test config: don't omit default language, use auto trailing slash defaultLanguage: undefined, trailingSlash: "auto", - aliases: { - "branch-alias": { - context: { - repo: ["tidb", "tidb-in-kubernetes"], - }, - mappings: { - master: "stable", - main: "stable", - "release-*": "v*", - }, - }, - }, }; it("should resolve tidbcloud dedicated _index to /tidbcloud (first rule)", () => { @@ -168,7 +156,7 @@ describe("calculateFileUrl", () => { it("should resolve develop _index with folders", () => { const absolutePath = path.join( sourceBasePath, - "en/tidb/master/develop/subfolder/_index.md" + "en/tidb/release-8.5/develop/subfolder/_index.md" ); const url = calculateFileUrlWithConfig(absolutePath, testConfig); expect(url).toBe("/en/develop/subfolder"); @@ -177,34 +165,35 @@ describe("calculateFileUrl", () => { it("should resolve develop non-index without folders", () => { const absolutePath = path.join( sourceBasePath, - "en/tidb/master/develop/subfolder/some-page.md" + "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 -> stable)", () => { + 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/stable/alert-rules/"); + expect(url).toBe("/en/tidb/dev/alert-rules/"); }); - it("should resolve tidb with branch alias (release-8.5 -> v8.5)", () => { + 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); - expect(url).toBe("/en/tidb/v8.5/alert-rules/"); + // 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/stable"); + expect(url).toBe("/en/tidb/dev"); }); it("should resolve tidb with folders and branch alias", () => { @@ -213,13 +202,13 @@ describe("calculateFileUrl", () => { "en/tidb/master/subfolder/page.md" ); const url = calculateFileUrlWithConfig(absolutePath, testConfig); - expect(url).toBe("/en/tidb/stable/page/"); + expect(url).toBe("/en/tidb/dev/page/"); }); it("should resolve api folder", () => { const absolutePath = path.join( sourceBasePath, - "en/tidb/master/api/overview.md" + "en/tidb/release-8.5/api/overview.md" ); const url = calculateFileUrlWithConfig(absolutePath, testConfig); expect(url).toBe("/en/api/overview/"); @@ -228,7 +217,7 @@ describe("calculateFileUrl", () => { it("should resolve best-practice folder", () => { const absolutePath = path.join( sourceBasePath, - "en/tidb/master/best-practice/guide.md" + "en/tidb/release-8.5/best-practice/guide.md" ); const url = calculateFileUrlWithConfig(absolutePath, testConfig); expect(url).toBe("/en/best-practice/guide/"); @@ -237,10 +226,12 @@ describe("calculateFileUrl", () => { it("should resolve releases folder", () => { const absolutePath = path.join( sourceBasePath, - "en/tidb/master/releases/v8.5.md" + "en/tidb/release-8.5/releases/_index.md" ); const url = calculateFileUrlWithConfig(absolutePath, testConfig); - expect(url).toBe("/en/releases/v8.5/"); + // 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", () => { @@ -266,6 +257,16 @@ describe("calculateFileUrl", () => { 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'", () => { @@ -291,7 +292,8 @@ describe("calculateFileUrl with defaultLanguage: 'en'", () => { configWithDefaultLang, true ); - expect(url).toBe("/tidb/stable/alert-rules"); + // master -> dev via branch-alias-tidb + expect(url).toBe("/tidb/dev/alert-rules"); }); it("should omit /en/ prefix for English files (tidbcloud)", () => { @@ -327,7 +329,7 @@ describe("calculateFileUrl with defaultLanguage: 'en'", () => { it("should omit /en/ prefix for English develop files", () => { const absolutePath = path.join( sourceBasePath, - "en/tidb/master/develop/overview.md" + "en/tidb/release-8.5/develop/overview.md" ); const url = calculateFileUrlWithConfig( absolutePath, @@ -343,22 +345,24 @@ describe("calculateFileUrl with defaultLanguage: 'en'", () => { "zh/tidb/master/alert-rules.md" ); const url = calculateFileUrlWithConfig(absolutePath, configWithDefaultLang); - expect(url).toBe("/zh/tidb/stable/alert-rules"); + // 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/master/alert-rules.md" + "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/master/api/overview.md" + "en/tidb/release-8.5/api/overview.md" ); const url = calculateFileUrlWithConfig( absolutePath, @@ -378,7 +382,8 @@ describe("calculateFileUrl with defaultLanguage: 'en'", () => { configWithDefaultLang, true ); - expect(url).toBe("/tidb/v8.5/alert-rules"); + // release-8.5 -> stable via branch-alias-tidb (exact match takes precedence) + expect(url).toBe("/tidb/stable/alert-rules"); }); }); @@ -413,9 +418,21 @@ describe("calculateFileUrl with slug format (relative path)", () => { // tidb with branch { sourcePattern: "/{lang}/{repo}/{branch}/{...folders}/{filename}", - targetPattern: "/{lang}/{repo}/{branch:branch-alias}/{filename}", + targetPattern: "/{lang}/{repo}/{branch:branch-alias-tidb}/{filename}", conditions: { - repo: ["tidb", "tidb-in-kubernetes"], + 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"], @@ -431,12 +448,17 @@ describe("calculateFileUrl with slug format (relative path)", () => { }, ], aliases: { - "branch-alias": { - context: { - repo: ["tidb", "tidb-in-kubernetes"], + "branch-alias-tidb": { + mappings: { + master: "dev", + "release-8.5": "stable", + "release-*": "v*", }, + }, + "branch-alias-tidb-in-kubernetes": { mappings: { - master: "stable", + main: "dev", + "release-1.6": "stable", "release-*": "v*", }, }, @@ -446,7 +468,8 @@ describe("calculateFileUrl with slug format (relative path)", () => { it("should resolve slug format for tidb files", () => { const slug = "en/tidb/master/alert-rules"; const url = calculateFileUrlWithConfig(slug, configWithDefaultLang, true); - expect(url).toBe("/tidb/stable/alert-rules"); + // master -> dev via branch-alias-tidb + expect(url).toBe("/tidb/dev/alert-rules"); }); it("should resolve slug format for tidbcloud files", () => { @@ -464,13 +487,15 @@ describe("calculateFileUrl with slug format (relative path)", () => { it("should resolve slug format with leading slash", () => { const slug = "/en/tidb/master/alert-rules"; const url = calculateFileUrlWithConfig(slug, configWithDefaultLang, true); - expect(url).toBe("/tidb/stable/alert-rules"); + // 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); - expect(url).toBe("/zh/tidb/stable/alert-rules"); + // master -> dev via branch-alias-tidb + expect(url).toBe("/zh/tidb/dev/alert-rules"); }); it("should return null for invalid slug format", () => { @@ -499,7 +524,8 @@ describe("calculateFileUrl with omitDefaultLanguage parameter", () => { "en/tidb/master/alert-rules.md" ); const url = calculateFileUrlWithConfig(absolutePath, configWithDefaultLang); - expect(url).toBe("/en/tidb/stable/alert-rules"); + // master -> dev via branch-alias-tidb + expect(url).toBe("/en/tidb/dev/alert-rules"); }); it("should keep default language when omitDefaultLanguage is undefined (default)", () => { @@ -512,7 +538,8 @@ describe("calculateFileUrl with omitDefaultLanguage parameter", () => { configWithDefaultLang, false ); - expect(url).toBe("/en/tidb/stable/alert-rules"); + // master -> dev via branch-alias-tidb + expect(url).toBe("/en/tidb/dev/alert-rules"); }); it("should omit default language when omitDefaultLanguage is true", () => { @@ -525,7 +552,8 @@ describe("calculateFileUrl with omitDefaultLanguage parameter", () => { configWithDefaultLang, true ); - expect(url).toBe("/tidb/stable/alert-rules"); + // master -> dev via branch-alias-tidb + expect(url).toBe("/tidb/dev/alert-rules"); }); it("should keep non-default language even when omitDefaultLanguage is false", () => { @@ -538,7 +566,8 @@ describe("calculateFileUrl with omitDefaultLanguage parameter", () => { configWithDefaultLang, false ); - expect(url).toBe("/zh/tidb/stable/alert-rules"); + // master -> dev via branch-alias-tidb + expect(url).toBe("/zh/tidb/dev/alert-rules"); }); it("should keep non-default language when omitDefaultLanguage is true", () => { @@ -551,6 +580,7 @@ describe("calculateFileUrl with omitDefaultLanguage parameter", () => { configWithDefaultLang, true ); - expect(url).toBe("/zh/tidb/stable/alert-rules"); + // master -> dev via branch-alias-tidb + expect(url).toBe("/zh/tidb/dev/alert-rules"); }); }); diff --git a/gatsby/url-resolver/index.ts b/gatsby/url-resolver/index.ts index a7b8ca39..e46f39df 100644 --- a/gatsby/url-resolver/index.ts +++ b/gatsby/url-resolver/index.ts @@ -16,7 +16,7 @@ export type { } from "./types"; // Export URL resolver functions -export { parseSourcePath, calculateFileUrl, calculateFileUrlWithConfig } from "./url-resolver"; +export { parseSourcePath, calculateFileUrl, calculateFileUrlWithConfig, clearUrlResolverCache } from "./url-resolver"; // Export pattern matcher utilities (for advanced use cases) export { matchPattern, applyPattern } from "./pattern-matcher"; diff --git a/gatsby/url-resolver/pattern-matcher.ts b/gatsby/url-resolver/pattern-matcher.ts index aa0c0f23..c101b38f 100644 --- a/gatsby/url-resolver/pattern-matcher.ts +++ b/gatsby/url-resolver/pattern-matcher.ts @@ -4,6 +4,9 @@ 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 @@ -17,10 +20,15 @@ export function matchPattern( pattern: string, segments: string[] ): Record | null { - const patternParts = pattern - .split("/") - .filter((p) => p.length > 0) - .filter((p) => !p.startsWith("/")); + // 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; @@ -132,10 +140,15 @@ export function applyPattern( }; } ): string { - const parts = pattern - .split("/") - .filter((p) => p.length > 0) - .filter((p) => !p.startsWith("/")); + // 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) { @@ -201,3 +214,10 @@ export function applyPattern( 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/url-resolver.ts b/gatsby/url-resolver/url-resolver.ts index ee8a54e6..be6bfcdb 100644 --- a/gatsby/url-resolver/url-resolver.ts +++ b/gatsby/url-resolver/url-resolver.ts @@ -7,9 +7,23 @@ import type { UrlResolverConfig, ParsedSourcePath, } from "./types"; -import { matchPattern, applyPattern } from "./pattern-matcher"; +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 @@ -27,6 +41,13 @@ 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(/\/$/, ""); @@ -83,10 +104,14 @@ export function parseSourcePath( // Update segments array to include .md extension if it was added segments[segments.length - 1] = lastSegment; - return { + const result: ParsedSourcePath = { segments, filename, }; + + // Cache the result + parsedPathCache.set(cacheKey, result); + return result; } /** @@ -122,8 +147,17 @@ export function calculateFileUrlWithConfig( 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; } @@ -243,9 +277,15 @@ export function calculateFileUrlWithConfig( } // "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; } @@ -267,3 +307,13 @@ export function calculateFileUrl( omitDefaultLanguage ); } + +/** + * Clear all caches (useful for testing or when config changes) + */ +export function clearUrlResolverCache(): void { + fileUrlCache.clear(); + parsedPathCache.clear(); + // Also clear pattern cache + clearPatternCache(); +} From 67f4e63a535af90de2e3cab1eae00e64f982efb7 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Thu, 15 Jan 2026 00:43:00 +0800 Subject: [PATCH 37/37] refactor: extend TOCNamespace for enhanced navigation in templates - Added new TOCNamespace values for NotFound, Search, and CloudAPIApp to improve navigation context. - Updated relevant templates (404Template, CloudAPIReferenceTemplate, DocSearchTemplate) to utilize the new namespace prop in Layout for better type safety and clarity. - Enhanced overall maintainability and consistency in navigation logic across documentation components. --- src/shared/interface.ts | 6 ++++++ src/templates/404Template.tsx | 7 ++++++- src/templates/CloudAPIReferenceTemplate.tsx | 8 ++++++-- src/templates/DocSearchTemplate.tsx | 4 ++-- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/shared/interface.ts b/src/shared/interface.ts index b9360e92..21a98189 100644 --- a/src/shared/interface.ts +++ b/src/shared/interface.ts @@ -11,6 +11,9 @@ export interface TableOfContent { export enum TOCNamespace { Home = "home", + NotFound = "not-found", + Search = "search", + CloudAPIApp = "cloud-api-app", TiDB = "tidb", TiDBCloud = "tidb-cloud", TiDBInKubernetes = "tidb-in-kubernetes", @@ -24,6 +27,9 @@ export enum TOCNamespace { export const TOCNamespaceSlugMap: Record = { [TOCNamespace.Home]: "", + [TOCNamespace.NotFound]: "", + [TOCNamespace.Search]: "", + [TOCNamespace.CloudAPIApp]: "", [TOCNamespace.TiDB]: "", [TOCNamespace.TiDBCloud]: "", [TOCNamespace.TiDBInKubernetes]: "", diff --git a/src/templates/404Template.tsx b/src/templates/404Template.tsx index 60bf52cd..f9b520d3 100644 --- a/src/templates/404Template.tsx +++ b/src/templates/404Template.tsx @@ -16,6 +16,7 @@ import { getHeaderHeight } from "shared/headerHeight"; import CONFIG from "../../docs/docs.json"; import { useEffect, useRef, useState } from "react"; +import { TOCNamespace } from "shared/interface"; interface AllLocales { locales: { edges: { @@ -120,7 +121,11 @@ export default function PageNotFoundTemplate({ <> - + diff --git a/src/templates/DocSearchTemplate.tsx b/src/templates/DocSearchTemplate.tsx index cfcbe7f1..028aeb08 100644 --- a/src/templates/DocSearchTemplate.tsx +++ b/src/templates/DocSearchTemplate.tsx @@ -27,7 +27,7 @@ import { ZH_DOC_TYPE_LIST, TIDB_ZH_SEARCH_INDEX_VERSION, } from "shared/resources"; -import { Locale } from "shared/interface"; +import { Locale, TOCNamespace } from "shared/interface"; import { getHeaderHeight } from "shared/headerHeight"; import { FeedbackSurveyCampaign } from "components/Campaign/FeedbackSurvey"; import { useEffect } from "react"; @@ -201,7 +201,7 @@ export default function DocSearchTemplate({ return ( <> - +