Skip to content
9 changes: 6 additions & 3 deletions apps/site/app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -29,9 +30,11 @@ const RootLayout: FC<PropsWithChildren> = async ({ children }) => {
>
<body suppressHydrationWarning>
<NextIntlClientProvider>
<ThemeProvider>
<BaseLayout>{children}</BaseLayout>
</ThemeProvider>
<HashProvider>
<ThemeProvider>
<BaseLayout>{children}</BaseLayout>
</ThemeProvider>
</HashProvider>
</NextIntlClientProvider>

<a
Expand Down
25 changes: 25 additions & 0 deletions apps/site/hooks/useHash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
'use client';

import { useSyncExternalStore, useCallback } from 'react';

const subscribe = (callback: () => 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;
2 changes: 2 additions & 0 deletions apps/site/mdx/components.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -58,6 +59,7 @@ export default {
AlertBox,
// Renders MDX CodeTabs
CodeTabs: MDXCodeTabs,
CodeTabsWithHash,
// Renders Tooltips
MDXTooltip,
MDXTooltipContent,
Expand Down
13 changes: 13 additions & 0 deletions apps/site/providers/hashProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<PropsWithChildren> = ({ children }) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Have Location be passed to the HashProvider ;)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BTW, I thought of it, but the location on the server will not contain the hash in the URL. This means the server can't read the initial hash to pass it to the Provider, causing an hydration mismatch error. Then we will need to handle it in the provider like this:

const [hash, setHash] = useState('');
useEffect(() => {
  setHash(location.hash);
}, [location.hash]);

and that causes a not needed rerendering. That's why I used the new hook useSyncExternalStore that lets us handle client and server natively without errors or another rendering.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replied your other comment

const [hash, setHash] = useHash();

return <HashContext value={{ hash, setHash }}>{children}</HashContext>;
};
9 changes: 8 additions & 1 deletion packages/rehype-shiki/src/plugin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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])) {
Expand All @@ -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
Expand All @@ -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,
},
};

Expand Down
2 changes: 1 addition & 1 deletion packages/ui-components/src/Common/CodeTabs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import styles from './index.module.css';

type CodeTabsProps = Pick<
ComponentProps<typeof Tabs>,
'tabs' | 'defaultValue' | 'children' | 'addons'
'tabs' | 'defaultValue' | 'value' | 'onValueChange' | 'children' | 'addons'
>;

const CodeTabs: FC<CodeTabsProps> = ({ ...props }) => (
Expand Down
2 changes: 2 additions & 0 deletions packages/ui-components/src/Common/Tabs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import styles from './index.module.css';

type Tab = {
key: string;
id?: string;
label: string;
secondaryLabel?: string;
value?: string;
Expand Down Expand Up @@ -34,6 +35,7 @@ const Tabs: FC<PropsWithChildren<TabsProps>> = ({
{tabs.map(tab => (
<TabsPrimitive.Trigger
key={tab.key}
id={tab.id}
value={tab.value ?? tab.key}
className={classNames(styles.tabsTrigger, triggerClassName)}
>
Expand Down
89 changes: 89 additions & 0 deletions packages/ui-components/src/MDX/CodeTabs/CodeTabsWithHash.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof MDXCodeTabs> & {
groupId: string;
};

const NAME_OVERRIDES: Record<string, string | undefined> = {
mjs: 'ESM',
};

const CodeTabsWithHash: FC<CodeTabsWithHashProps> = ({
languages: rawLanguages,
displayNames: rawDisplayNames,
children: codes,
defaultTab = '0',
groupId,
...props
}) => {
const { hash, setHash } = useHashContext();

const { tabs, languages } = useMemo(() => {
const occurrences: Record<string, number> = {};
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 (
<CodeTabs
tabs={tabs}
value={activeTab}
onValueChange={handleValueChange}
{...props}
>
{languages.map((_, index) => (
<TabsPrimitive.Content
forceMount
key={tabs[index].key}
value={tabs[index].key}
>
{codes[index]}
</TabsPrimitive.Content>
))}
</CodeTabs>
);
};
export default CodeTabsWithHash;
15 changes: 15 additions & 0 deletions packages/ui-components/src/contexts/HashContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
'use client';

import { createContext, use } from 'react';

type HashContextType = {
hash: string;
setHash: (newHash: string) => void;
};

export const HashContext = createContext<HashContextType>({
hash: '',
setHash: () => {},
});

export const useHashContext = () => use(HashContext);