Skip to content
Merged
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 }) => {
Comment thread
ovflowd marked this conversation as resolved.
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);
Comment thread
ovflowd marked this conversation as resolved.
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);
Loading