diff --git a/apps/site/app/[locale]/layout.tsx b/apps/site/app/[locale]/layout.tsx index 3e1c358b1103f..6f46973abc806 100644 --- a/apps/site/app/[locale]/layout.tsx +++ b/apps/site/app/[locale]/layout.tsx @@ -6,6 +6,7 @@ import { getLocale } from 'next-intl/server'; import BaseLayout from '#site/layouts/Base'; import { IBM_PLEX_MONO, OPEN_SANS } from '#site/next.fonts'; +import { HashProvider } from '#site/providers/hashProvider'; import { ThemeProvider } from '#site/providers/themeProvider'; import type { FC, PropsWithChildren } from 'react'; @@ -29,9 +30,11 @@ const RootLayout: FC = async ({ children }) => { > - - {children} - + + + {children} + + void) => { + window.addEventListener('hashchange', callback); + return () => window.removeEventListener('hashchange', callback); +}; + +const getSnapshot = () => window.location.hash.slice(1); +const getServerSnapshot = () => ''; + +const useHash = () => { + // Works in both client and server environments, provides the initial value for the store during server rendering and hydration, avoiding hydration mismatches + const hash = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + + const setHash = useCallback((newHash: string) => { + window.history.replaceState(null, '', `#${newHash}`); + window.dispatchEvent(new Event('hashchange')); + }, []); + + return [hash, setHash] as const; +}; + +export default useHash; diff --git a/apps/site/mdx/components.mjs b/apps/site/mdx/components.mjs index 46db7dc2afe11..7ae15c06dba08 100644 --- a/apps/site/mdx/components.mjs +++ b/apps/site/mdx/components.mjs @@ -4,6 +4,7 @@ import AlertBox from '@node-core/ui-components/Common/AlertBox'; import BadgeGroup from '@node-core/ui-components/Common/BadgeGroup'; import Blockquote from '@node-core/ui-components/Common/Blockquote'; import MDXCodeTabs from '@node-core/ui-components/MDX/CodeTabs'; +import CodeTabsWithHash from '@node-core/ui-components/MDX/CodeTabs/CodeTabsWithHash'; import { MDXTooltip, MDXTooltipContent, @@ -58,6 +59,7 @@ export default { AlertBox, // Renders MDX CodeTabs CodeTabs: MDXCodeTabs, + CodeTabsWithHash, // Renders Tooltips MDXTooltip, MDXTooltipContent, diff --git a/apps/site/providers/hashProvider.tsx b/apps/site/providers/hashProvider.tsx new file mode 100644 index 0000000000000..ad4439dfb8d01 --- /dev/null +++ b/apps/site/providers/hashProvider.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { HashContext } from '@node-core/ui-components/contexts/HashContext'; + +import useHash from '#site/hooks/useHash'; + +import type { FC, PropsWithChildren } from 'react'; + +export const HashProvider: FC = ({ children }) => { + const [hash, setHash] = useHash(); + + return {children}; +}; diff --git a/packages/rehype-shiki/src/plugin.mjs b/packages/rehype-shiki/src/plugin.mjs index 6f08603fd7293..384ecbfb07b0f 100644 --- a/packages/rehype-shiki/src/plugin.mjs +++ b/packages/rehype-shiki/src/plugin.mjs @@ -67,6 +67,7 @@ export default async function rehypeShikiji(options) { const codeTabsChildren = []; let defaultTab = '0'; + let groupId; let currentIndex = index; while (isCodeBlock(parent?.children[currentIndex])) { @@ -84,6 +85,11 @@ export default async function rehypeShikiji(options) { // Map the display names of each variant for the CodeTab displayNames.push(meta.displayName?.replaceAll('|', '') ?? ''); + // Extract groupId if it exists + if (meta.groupId) { + groupId = meta.groupId; + } + codeTabsChildren.push(parent?.children[currentIndex]); // If `active="true"` is provided in a CodeBox @@ -102,12 +108,13 @@ export default async function rehypeShikiji(options) { if (codeTabsChildren.length >= 2) { const codeTabElement = { type: 'element', - tagName: 'CodeTabs', + tagName: groupId ? 'CodeTabsWithHash' : 'CodeTabs', children: codeTabsChildren, properties: { languages: languages.join('|'), displayNames: displayNames.join('|'), defaultTab, + groupId, }, }; diff --git a/packages/ui-components/src/Common/CodeTabs/index.tsx b/packages/ui-components/src/Common/CodeTabs/index.tsx index 12ff05973037e..910fda8abfc58 100644 --- a/packages/ui-components/src/Common/CodeTabs/index.tsx +++ b/packages/ui-components/src/Common/CodeTabs/index.tsx @@ -6,7 +6,7 @@ import styles from './index.module.css'; type CodeTabsProps = Pick< ComponentProps, - 'tabs' | 'defaultValue' | 'children' | 'addons' + 'tabs' | 'defaultValue' | 'value' | 'onValueChange' | 'children' | 'addons' >; const CodeTabs: FC = ({ ...props }) => ( diff --git a/packages/ui-components/src/Common/Tabs/index.tsx b/packages/ui-components/src/Common/Tabs/index.tsx index bd2f769b596a6..c20e40eeaccf2 100644 --- a/packages/ui-components/src/Common/Tabs/index.tsx +++ b/packages/ui-components/src/Common/Tabs/index.tsx @@ -7,6 +7,7 @@ import styles from './index.module.css'; type Tab = { key: string; + id?: string; label: string; secondaryLabel?: string; value?: string; @@ -34,6 +35,7 @@ const Tabs: FC> = ({ {tabs.map(tab => ( diff --git a/packages/ui-components/src/MDX/CodeTabs/CodeTabsWithHash.tsx b/packages/ui-components/src/MDX/CodeTabs/CodeTabsWithHash.tsx new file mode 100644 index 0000000000000..9bf663270739e --- /dev/null +++ b/packages/ui-components/src/MDX/CodeTabs/CodeTabsWithHash.tsx @@ -0,0 +1,89 @@ +'use client'; + +import * as TabsPrimitive from '@radix-ui/react-tabs'; +import { useCallback, useMemo } from 'react'; + +import CodeTabs from '#ui/Common/CodeTabs'; +import { useHashContext } from '#ui/contexts/HashContext'; + +import type { ComponentProps, FC } from 'react'; + +import MDXCodeTabs from './index'; + +type CodeTabsWithHashProps = ComponentProps & { + groupId: string; +}; + +const NAME_OVERRIDES: Record = { + mjs: 'ESM', +}; + +const CodeTabsWithHash: FC = ({ + languages: rawLanguages, + displayNames: rawDisplayNames, + children: codes, + defaultTab = '0', + groupId, + ...props +}) => { + const { hash, setHash } = useHashContext(); + + const { tabs, languages } = useMemo(() => { + const occurrences: Record = {}; + const languages = rawLanguages.split('|'); + const displayNames = rawDisplayNames?.split('|') ?? []; + + const tabs = languages.map((language, index) => { + const base = + displayNames[index]?.trim() || + NAME_OVERRIDES[language] || + language.toUpperCase(); + + const count = occurrences[base] ?? 0; + occurrences[base] = count + 1; + + const label = count > 0 ? `${base} (${count + 1})` : base; + + return { + key: `${language}-${index}`, + label, + id: `${groupId}-${language}-${index}`.replace(/[^a-zA-Z0-9-_]/g, '-'), + }; + }); + + return { tabs, languages }; + }, [rawLanguages, rawDisplayNames, groupId]); + + const activeTab = + tabs.find(t => t.id === hash)?.key ?? tabs[Number(defaultTab)].key; + + const handleValueChange = useCallback( + (value: string) => { + const matched = tabs.find(t => t.key === value); + if (matched?.id) { + setHash(matched.id); + } + }, + [tabs, setHash] + ); + + return ( + + {languages.map((_, index) => ( + + {codes[index]} + + ))} + + ); +}; +export default CodeTabsWithHash; diff --git a/packages/ui-components/src/MDX/CodeTabs.tsx b/packages/ui-components/src/MDX/CodeTabs/index.tsx similarity index 100% rename from packages/ui-components/src/MDX/CodeTabs.tsx rename to packages/ui-components/src/MDX/CodeTabs/index.tsx diff --git a/packages/ui-components/src/contexts/HashContext.tsx b/packages/ui-components/src/contexts/HashContext.tsx new file mode 100644 index 0000000000000..7a738fd53eea7 --- /dev/null +++ b/packages/ui-components/src/contexts/HashContext.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { createContext, use } from 'react'; + +type HashContextType = { + hash: string; + setHash: (newHash: string) => void; +}; + +export const HashContext = createContext({ + hash: '', + setHash: () => {}, +}); + +export const useHashContext = () => use(HashContext);