diff --git a/.storybook/main.ts b/.storybook/main.ts index e559e3a2fed19..66a60e7afaa37 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -5,7 +5,7 @@ const rootClasses = classNames( // note: this is hard-coded sadly as next/font can only be loaded within next.js context '__variable_open-sans-normal', // note: this is hard-coded sadly as next/font can only be loaded within next.js context - '__variable_ibm-plex-mono-normal-600' + '__variable_ibm-plex-mono-normal' ); const config: StorybookConfig = { diff --git a/components/Common/CodeBox/index.module.css b/components/Common/CodeBox/index.module.css new file mode 100644 index 0000000000000..6eda9fb80dd02 --- /dev/null +++ b/components/Common/CodeBox/index.module.css @@ -0,0 +1,88 @@ +.root { + @apply w-full + rounded + border + border-neutral-900 + bg-neutral-950; + + .content { + @apply m-0 + p-4; + + & > code { + @apply grid + bg-transparent + p-0 + font-ibm-plex-mono + text-sm + font-regular + leading-snug + text-neutral-400 + [counter-reset:line]; + + & > [class='line'] { + @apply relative + min-w-0 + pl-8; + + & > span { + @apply whitespace-break-spaces + break-words; + } + + &:not(:empty:last-child)::before { + @apply inline-block + content-['']; + } + + &:not(:empty:last-child)::after { + @apply absolute + left-0 + top-0 + mr-4 + w-4.5 + text-right + text-neutral-600 + [content:counter(line)] + [counter-increment:line]; + } + } + } + } + + & > .footer { + @apply flex + items-center + justify-between + border-t + border-t-neutral-900 + px-4 + py-3 + text-sm + font-medium; + + & > .language { + @apply text-neutral-400; + } + + & > .action { + @apply flex + cursor-pointer + items-center + gap-2 + px-3 + py-1.5; + } + } +} + +.notification { + @apply flex + items-center + gap-3; +} + +.icon { + @apply h-4 + w-4; +} diff --git a/components/Common/CodeBox/index.stories.tsx b/components/Common/CodeBox/index.stories.tsx new file mode 100644 index 0000000000000..f44b33e17707d --- /dev/null +++ b/components/Common/CodeBox/index.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; + +import CodeBox from '@/components/Common/CodeBox'; + +type Story = StoryObj<typeof CodeBox>; +type Meta = MetaObj<typeof CodeBox>; + +const content = `const http = require('http'); + +const hostname = '127.0.0.1'; +const port = 3000; + +const server = http.createServer((req, res) => { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end('Hello World'); +}); + +server.listen(port, hostname, () => { + console.log(\`Server running at http://\${hostname}:\${port}/\`); +});`; + +export const Default: Story = { + args: { + language: 'JavaScript (CJS)', + children: <code>{content}</code>, + }, +}; + +export const WithCopyButton: Story = { + args: { + language: 'JavaScript (CJS)', + showCopyButton: true, + children: <code>{content}</code>, + }, +}; + +export default { component: CodeBox } as Meta; diff --git a/components/Common/CodeBox/index.tsx b/components/Common/CodeBox/index.tsx new file mode 100644 index 0000000000000..e02defe14ada2 --- /dev/null +++ b/components/Common/CodeBox/index.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { + DocumentDuplicateIcon, + CodeBracketIcon, +} from '@heroicons/react/24/outline'; +import { useTranslations } from 'next-intl'; +import type { FC, PropsWithChildren, ReactNode } from 'react'; +import { Fragment, isValidElement, useRef } from 'react'; + +import Button from '@/components/Common/Button'; +import { useCopyToClipboard, useNotification } from '@/hooks'; +import { ENABLE_WEBSITE_REDESIGN } from '@/next.constants.mjs'; + +import styles from './index.module.css'; + +// Transforms a code element with plain text content into a more structured +// format for rendering with line numbers +const transformCode = (code: ReactNode): ReactNode => { + if (!isValidElement(code)) { + // Early return when the `CodeBox` child is not a valid element since the + // type is a ReactNode, and can assume any value + return code; + } + + const content = code.props?.children; + + if (code.type !== 'code' || typeof content !== 'string') { + // There is no need to transform an element that is not a code element or + // a content that is not a string + return code; + } + + const lines = content.split('\n'); + + return ( + <code> + {lines.flatMap((line, lineIndex) => { + const columns = line.split(' '); + + return [ + <span key={lineIndex} className="line"> + {columns.map((column, columnIndex) => ( + <Fragment key={columnIndex}> + <span>{column}</span> + {columnIndex < columns.length - 1 && <span> </span>} + </Fragment> + ))} + </span>, + // Add a break line so the text content is formatted correctly + // when copying to clipboard + '\n', + ]; + })} + </code> + ); +}; + +type CodeBoxProps = { language: string; showCopyButton?: boolean }; + +const CodeBox: FC<PropsWithChildren<CodeBoxProps>> = ({ + children, + language, + // For now we only want to render the Copy Button by default + // if the Website Redesign is Enabled + showCopyButton = ENABLE_WEBSITE_REDESIGN, +}) => { + const ref = useRef<HTMLPreElement>(null); + + const notify = useNotification(); + const [, copyToClipboard] = useCopyToClipboard(); + const t = useTranslations(); + + const onCopy = async () => { + if (ref.current?.textContent) { + copyToClipboard(ref.current.textContent); + + notify({ + duration: 3000, + message: ( + <div className={styles.notification}> + <CodeBracketIcon className={styles.icon} /> + {t('components.common.codebox.copied')} + </div> + ), + }); + } + }; + + return ( + <div className={styles.root}> + <pre ref={ref} className={styles.content} tabIndex={0}> + {transformCode(children)} + </pre> + + {language && ( + <div className={styles.footer}> + <span className={styles.language}>{language}</span> + + {showCopyButton && ( + <Button type="button" className={styles.action} onClick={onCopy}> + <DocumentDuplicateIcon className={styles.icon} /> + {t('components.common.codebox.copy')} + </Button> + )} + </div> + )} + </div> + ); +}; + +export default CodeBox; diff --git a/components/Common/CodeTabs/index.module.css b/components/Common/CodeTabs/index.module.css new file mode 100644 index 0000000000000..ef33ee4a2ac95 --- /dev/null +++ b/components/Common/CodeTabs/index.module.css @@ -0,0 +1,51 @@ +.root > [role='tabpanel'] > :first-child { + @apply rounded-t-none; +} + +.header { + @apply flex + rounded-t + border-x + border-t + border-neutral-900 + bg-neutral-950 + px-4 + pr-5 + pt-3; + + & [role='tab'] { + @apply border-b + border-b-transparent + px-1 + text-neutral-200; + + &[aria-selected='true'] { + @apply border-b-green-400 + text-green-400; + } + } +} + +.link { + @apply flex + items-center + gap-2 + text-center + text-neutral-200; + + & > .icon { + @apply h-4 + w-4 + text-neutral-300; + } + + &:is(:link, :visited) { + &:hover { + @apply text-neutral-400; + + & > .icon { + @apply text-neutral-600; + } + } + } +} diff --git a/components/Common/CodeTabs/index.stories.tsx b/components/Common/CodeTabs/index.stories.tsx new file mode 100644 index 0000000000000..b6a3b9e5f0b9c --- /dev/null +++ b/components/Common/CodeTabs/index.stories.tsx @@ -0,0 +1,75 @@ +import * as TabsPrimitive from '@radix-ui/react-tabs'; +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; +import type { FC } from 'react'; + +import CodeBox from '@/components/Common/CodeBox'; +import CodeTabs from '@/components/Common/CodeTabs'; + +type Story = StoryObj<typeof CodeTabs>; +type Meta = MetaObj<typeof CodeTabs>; + +const mjsContent = `import * as http from 'http'; + +const hostname = '127.0.0.1'; +const port = 3000; + +const server = http.createServer((req, res) => { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end('Hello World'); +}); + +server.listen(port, hostname, () => { + console.log(\`Server running at http://\${hostname}:\${port}/\`); +});`; + +const cjsContent = `const http = require('http'); + +const hostname = '127.0.0.1'; +const port = 3000; + +const server = http.createServer((req, res) => { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end('Hello World'); +}); + +server.listen(port, hostname, () => { + console.log(\`Server running at http://\${hostname}:\${port}/\`); +});`; + +const TabsContent: FC = () => ( + <> + <TabsPrimitive.Content key="mjs" value="mjs"> + <CodeBox language="JavaScript (MJS)" showCopyButton> + <code>{mjsContent}</code> + </CodeBox> + </TabsPrimitive.Content> + <TabsPrimitive.Content key="cjs" value="cjs"> + <CodeBox language="JavaScript (CJS)" showCopyButton> + <code>{cjsContent}</code> + </CodeBox> + </TabsPrimitive.Content> + </> +); + +export const Default: Story = {}; + +export const WithMoreOptions: Story = { + args: { + linkUrl: 'https://github.com/nodejs/nodejs.org', + linkText: 'More options', + }, +}; + +export default { + component: CodeTabs, + args: { + children: <TabsContent />, + defaultValue: 'mjs', + tabs: [ + { key: 'mjs', label: 'MJS' }, + { key: 'cjs', label: 'CJS' }, + ], + }, +} as Meta; diff --git a/components/Common/CodeTabs/index.tsx b/components/Common/CodeTabs/index.tsx new file mode 100644 index 0000000000000..b60b57d3c35b9 --- /dev/null +++ b/components/Common/CodeTabs/index.tsx @@ -0,0 +1,41 @@ +import { ArrowUpRightIcon } from '@heroicons/react/24/solid'; +import type { ComponentProps, FC, PropsWithChildren } from 'react'; + +import Tabs from '@/components/Common/Tabs'; +import { Link } from '@/navigation.mjs'; + +import styles from './index.module.css'; + +type CodeTabsProps = Pick< + ComponentProps<typeof Tabs>, + 'tabs' | 'onValueChange' | 'defaultValue' +> & { + linkUrl?: string; + linkText?: string; +}; + +const CodeTabs: FC<PropsWithChildren<CodeTabsProps>> = ({ + children, + linkUrl, + linkText, + ...props +}) => ( + <Tabs + {...props} + className={styles.root} + headerClassName={styles.header} + addons={ + linkUrl && + linkText && ( + <Link className={styles.link} href={linkUrl}> + {linkText} + <ArrowUpRightIcon className={styles.icon} /> + </Link> + ) + } + > + {children} + </Tabs> +); + +export default CodeTabs; diff --git a/components/Common/Tabs/__tests__/index.test.mjs b/components/Common/Tabs/__tests__/index.test.mjs index 31957b4f1a228..87678998ba525 100644 --- a/components/Common/Tabs/__tests__/index.test.mjs +++ b/components/Common/Tabs/__tests__/index.test.mjs @@ -5,15 +5,15 @@ import userEvent from '@testing-library/user-event'; import Tabs from '../index'; describe('Tabs', () => { - const tabs = [ - { key: 'package', label: 'Package Manager' }, - { key: 'prebuilt', label: 'Prebuilt Installer' }, - { key: 'source', label: 'Source Code' }, - ]; - - beforeEach(() => { - render( - <Tabs tabs={tabs} defaultValue="package"> + const Sut = ({ addons }) => { + const tabs = [ + { key: 'package', label: 'Package Manager' }, + { key: 'prebuilt', label: 'Prebuilt Installer' }, + { key: 'source', label: 'Source Code' }, + ]; + + return ( + <Tabs tabs={tabs} defaultValue="package" addons={addons}> <TabsPrimitive.Content value="package"> Package Manager </TabsPrimitive.Content> @@ -25,29 +25,27 @@ describe('Tabs', () => { </TabsPrimitive.Content> </Tabs> ); - }); - - it('renders the correct number of tabs', () => { - const tabElements = screen.getAllByRole('tab'); - expect(tabElements).toHaveLength(3); - }); + }; - it('renders the correct tab content when clicked', async () => { - const user = userEvent.setup(); + it('should render the correct number of tabs', () => { + render(<Sut />); - const beforeActiveTabPanel = screen.getAllByRole('tabpanel'); + expect(screen.getAllByRole('tab')).toHaveLength(3); + }); - expect(beforeActiveTabPanel).toHaveLength(1); + it('should render the correct tab content when clicked', async () => { + render(<Sut />); - expect(beforeActiveTabPanel.at(0)).toHaveTextContent('Package Manager'); + expect(screen.getByRole('tabpanel')).toHaveTextContent('Package Manager'); - const tabElements = screen.getAllByRole('tab'); - await user.click(tabElements.at(-1)); + await userEvent.click(screen.getByRole('tab', { name: 'Source Code' })); - const afterActiveTabPanel = screen.getAllByRole('tabpanel'); + expect(screen.getByRole('tabpanel')).toHaveTextContent('Source Code'); + }); - expect(afterActiveTabPanel).toHaveLength(1); + it('should render the given addons', async () => { + render(<Sut addons={<a href="/">addon</a>} />); - expect(afterActiveTabPanel.at(0)).toHaveTextContent('Source Code'); + expect(screen.getByRole('link', { name: 'addon' })).toBeInTheDocument(); }); }); diff --git a/components/Common/Tabs/index.module.css b/components/Common/Tabs/index.module.css index 1183a31fa86de..3f775eb984c8c 100644 --- a/components/Common/Tabs/index.module.css +++ b/components/Common/Tabs/index.module.css @@ -18,3 +18,17 @@ dark:data-[state=active]:text-green-400; } } + +.tabsWithAddons { + @apply flex + justify-between; + + & > .addons { + @apply border-b-2 + border-b-transparent + px-1 + pb-[11px] + text-sm + font-semibold; + } +} diff --git a/components/Common/Tabs/index.stories.tsx b/components/Common/Tabs/index.stories.tsx index 6b6671e60eb1c..5cd3767843033 100644 --- a/components/Common/Tabs/index.stories.tsx +++ b/components/Common/Tabs/index.stories.tsx @@ -1,41 +1,49 @@ import * as TabsPrimitive from '@radix-ui/react-tabs'; import type { Meta as MetaObj, StoryObj } from '@storybook/react'; +import type { ComponentProps } from 'react'; import Tabs from '@/components/Common/Tabs'; type Story = StoryObj<typeof Tabs>; type Meta = MetaObj<typeof Tabs>; +const defaultArgs: ComponentProps<typeof Tabs> = { + defaultValue: 'prebuilt', + tabs: [ + { + key: 'package', + label: 'Package Manager', + }, + { + key: 'prebuilt', + label: 'Prebuilt Installer', + }, + { + key: 'source', + label: 'Source Code', + }, + ], + children: ( + <> + <TabsPrimitive.Content value="package"> + Package Manager + </TabsPrimitive.Content> + <TabsPrimitive.Content value="prebuilt"> + Prebuilt Installer + </TabsPrimitive.Content> + <TabsPrimitive.Content value="source">Source Code</TabsPrimitive.Content> + </> + ), +}; + export const Default: Story = { + args: defaultArgs, +}; + +export const WithAddon: Story = { args: { - defaultValue: 'prebuilt', - tabs: [ - { - key: 'package', - label: 'Package Manager', - }, - { - key: 'prebuilt', - label: 'Prebuilt Installer', - }, - { - key: 'source', - label: 'Source Code', - }, - ], - children: ( - <> - <TabsPrimitive.Content value="package"> - Package Manager - </TabsPrimitive.Content> - <TabsPrimitive.Content value="prebuilt"> - Prebuilt Installer - </TabsPrimitive.Content> - <TabsPrimitive.Content value="source"> - Source Code - </TabsPrimitive.Content> - </> - ), + ...defaultArgs, + addons: 'addon', }, }; diff --git a/components/Common/Tabs/index.tsx b/components/Common/Tabs/index.tsx index 811b31319e754..e436e02e922d1 100644 --- a/components/Common/Tabs/index.tsx +++ b/components/Common/Tabs/index.tsx @@ -1,6 +1,6 @@ import * as TabsPrimitive from '@radix-ui/react-tabs'; import classNames from 'classnames'; -import type { FC, PropsWithChildren } from 'react'; +import type { FC, PropsWithChildren, ReactNode } from 'react'; import styles from './index.module.css'; @@ -11,28 +11,35 @@ type Tab = { type TabsProps = { tabs: Tab[]; + addons?: ReactNode; headerClassName?: string; } & TabsPrimitive.TabsProps; const Tabs: FC<PropsWithChildren<TabsProps>> = ({ tabs, + addons, headerClassName, children, ...props }) => ( <TabsPrimitive.Root {...props}> <TabsPrimitive.List - className={classNames(styles.tabsList, headerClassName)} + className={classNames(headerClassName, { + [styles.tabsWithAddons]: addons != null, + })} > - {tabs.map(tab => ( - <TabsPrimitive.Trigger - key={tab.key} - value={tab.key} - className={styles.tabsTrigger} - > - {tab.label} - </TabsPrimitive.Trigger> - ))} + <div className={classNames(styles.tabsList)}> + {tabs.map(tab => ( + <TabsPrimitive.Trigger + key={tab.key} + value={tab.key} + className={styles.tabsTrigger} + > + {tab.label} + </TabsPrimitive.Trigger> + ))} + </div> + {addons != null && <div className={styles.addons}>{addons}</div>} </TabsPrimitive.List> {children} </TabsPrimitive.Root> diff --git a/components/Downloads/ChangelogModal/index.module.css b/components/Downloads/ChangelogModal/index.module.css index 28a8b9c1e14b2..1fdc53b45dd95 100644 --- a/components/Downloads/ChangelogModal/index.module.css +++ b/components/Downloads/ChangelogModal/index.module.css @@ -24,9 +24,10 @@ p-8 focus:outline-none sm:mt-20 - sm:p-12 lg:w-2/3 - xl:w-1/2 + xl:w-3/5 + xl:p-12 + xs:p-6 dark:bg-neutral-950; } diff --git a/components/MDX/CodeBox/index.stories.tsx b/components/MDX/CodeBox/index.stories.tsx new file mode 100644 index 0000000000000..a460d95f8da7b --- /dev/null +++ b/components/MDX/CodeBox/index.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; +import { VFile } from 'vfile'; + +import { MDXRenderer } from '@/components/mdxRenderer'; +import { compileMDX } from '@/next.mdx.compiler.mjs'; + +type Props = { children: string }; + +type Story = StoryObj<Props>; +type Meta = MetaObj<Props>; + +export const Default: Story = { + args: { + children: `\`\`\`javascript +const http = require('http'); + +const hostname = '127.0.0.1'; +const port = 3000; + +const server = http.createServer((req, res) => { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end('Hello World'); +}); + +server.listen(port, hostname, () => { + console.log(\`Server running at http://\${hostname}:\${port}/\`); +}); +\`\`\``, + }, +}; + +export default { + title: 'MDX/CodeBox', + render: (_, { loaded: { Content } }) => Content, + loaders: [ + async ({ args }) => { + const { MDXContent } = await compileMDX(new VFile(args.children), 'mdx'); + + return { Content: <MDXRenderer Component={MDXContent} /> }; + }, + ], +} as Meta; diff --git a/components/MDX/CodeBox/index.tsx b/components/MDX/CodeBox/index.tsx new file mode 100644 index 0000000000000..ae1adbf9fd040 --- /dev/null +++ b/components/MDX/CodeBox/index.tsx @@ -0,0 +1,26 @@ +import type { FC, PropsWithChildren } from 'react'; + +import CodeBox from '@/components/Common/CodeBox'; +import { getLanguageDisplayName } from '@/util/getLanguageDisplayName'; + +type CodeBoxProps = { className?: string; showCopyButton?: boolean }; + +const MDXCodeBox: FC<PropsWithChildren<CodeBoxProps>> = ({ + children: code, + className, + showCopyButton, +}) => { + const matches = className?.match(/language-(?<language>.*)/); + const language = matches?.groups?.language ?? ''; + + return ( + <CodeBox + language={getLanguageDisplayName(language)} + showCopyButton={showCopyButton} + > + {code} + </CodeBox> + ); +}; + +export default MDXCodeBox; diff --git a/components/MDX/CodeTabs/index.stories.tsx b/components/MDX/CodeTabs/index.stories.tsx new file mode 100644 index 0000000000000..45889e0b47888 --- /dev/null +++ b/components/MDX/CodeTabs/index.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; +import { VFile } from 'vfile'; + +import { MDXRenderer } from '@/components/mdxRenderer'; +import { compileMDX } from '@/next.mdx.compiler.mjs'; + +type Props = { children: string }; + +type Story = StoryObj<Props>; +type Meta = MetaObj<Props>; + +export const Default: Story = { + args: { + children: `\`\`\`mjs +const { createHmac } = await import('node:crypto'); + +const secret = 'abcdefg'; +const hash = createHmac('sha256', secret) + .update('I love cupcakes') + .digest('hex'); + +console.log(hash); +// Prints: +// c0fa1bc00531bd78ef38c628449c5102aeabd49b5dc3a2a516ea6ea959d6658e +\`\`\` + +\`\`\`cjs displayName="CommonJS" showCopyButton="true" +const { createHmac } = require('node:crypto'); + +const secret = 'abcdefg'; +const hash = createHmac('sha256', secret) + .update('I love cupcakes') + .digest('hex'); + +console.log(hash); +// Prints: +// c0fa1bc00531bd78ef38c628449c5102aeabd49b5dc3a2a516ea6ea959d6658e +\`\`\``, + }, +}; + +export default { + title: 'MDX/CodeTabs', + render: (_, { loaded: { Content } }) => Content, + loaders: [ + async ({ args }) => { + const { MDXContent } = await compileMDX(new VFile(args.children), 'mdx'); + + return { Content: <MDXRenderer Component={MDXContent} /> }; + }, + ], +} as Meta; diff --git a/components/MDX/CodeTabs/index.tsx b/components/MDX/CodeTabs/index.tsx new file mode 100644 index 0000000000000..2a1a6736ced65 --- /dev/null +++ b/components/MDX/CodeTabs/index.tsx @@ -0,0 +1,42 @@ +'use client'; + +import * as TabsPrimitive from '@radix-ui/react-tabs'; +import type { FC, ReactElement } from 'react'; + +import CodeTabs from '@/components/Common/CodeTabs'; + +type MDXCodeTabsProps = { + children: Array<ReactElement>; + languages: string; + displayNames?: string; +}; + +const MDXCodeTabs: FC<MDXCodeTabsProps> = ({ + languages: rawLanguages, + displayNames: rawDisplayNames, + children: codes, +}) => { + const languages = rawLanguages.split('|'); + const displayNames = rawDisplayNames?.split('|') ?? []; + + const tabs = languages.map((language, index) => { + const displayName = displayNames[index]; + + return { + key: language, + label: displayName?.length ? displayName : language.toUpperCase(), + }; + }); + + return ( + <CodeTabs tabs={tabs} defaultValue={languages[0]}> + {languages.map((language, index) => ( + <TabsPrimitive.Content key={language} value={language}> + {codes[index]} + </TabsPrimitive.Content> + ))} + </CodeTabs> + ); +}; + +export default MDXCodeTabs; diff --git a/components/SideNavigation.tsx b/components/SideNavigation.tsx index 3d94cf9a1fcd1..0d26cfbae5284 100644 --- a/components/SideNavigation.tsx +++ b/components/SideNavigation.tsx @@ -20,7 +20,7 @@ const SideNavigation: FC<SideNavigationProps> = ({ const mapItems = (items: ReturnType<typeof getSideNavigation>) => { return items.map(([, { link, label, items }]) => ( - <li key={link}> + <li key={`${link}-${label}`}> {link ? <ActiveLink href={link}>{label}</ActiveLink> : label} {items && items.length > 0 && <ul>{mapItems(items)}</ul>} diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 30630043e987d..0c8476aabded4 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -130,6 +130,7 @@ "next": "Next" }, "codebox": { + "copy": "Copy to clipboard", "copied": "Copied to clipboard!" }, "pagination": { diff --git a/layouts/New/Base.tsx b/layouts/New/Base.tsx index a6fe36e418a41..1d93321cb23dc 100644 --- a/layouts/New/Base.tsx +++ b/layouts/New/Base.tsx @@ -1,9 +1,15 @@ +'use client'; + import type { FC, PropsWithChildren } from 'react'; +import { NotificationProvider } from '@/providers/notificationProvider'; + import styles from './layouts.module.css'; const BaseLayout: FC<PropsWithChildren> = ({ children }) => ( - <div className={styles.baseLayout}>{children}</div> + <NotificationProvider viewportClassName="absolute bottom-0 right-0 list-none"> + <div className={styles.baseLayout}>{children}</div> + </NotificationProvider> ); export default BaseLayout; diff --git a/next.fonts.ts b/next.fonts.ts index 0a3e50c7ba9b6..832357d1564eb 100644 --- a/next.fonts.ts +++ b/next.fonts.ts @@ -23,7 +23,7 @@ export const OPEN_SANS = Open_Sans({ // We then export a variable and class name to be used // within Tailwind (tailwind.config.ts) and Storybook (preview.js) export const IBM_PLEX_MONO = IBM_Plex_Mono({ - weight: ['600'], + weight: ['400', '600'], subsets: ['latin'], variable: '--font-ibm-plex-mono', }); diff --git a/next.mdx.mjs b/next.mdx.mjs index 65aed35c58b3d..54828346f0128 100644 --- a/next.mdx.mjs +++ b/next.mdx.mjs @@ -18,7 +18,8 @@ export const NEXT_REHYPE_PLUGINS = [ rehypeSlug, // Automatically add anchor links to headings (H1, ...) [rehypeAutolinkHeadings, { properties: { tabIndex: -1, class: 'anchor' } }], - // Adds our syntax highlighter (Shikiji) to Codeboxes + // Transforms sequential code elements into code tabs and + // adds our syntax highlighter (Shikiji) to Codeboxes rehypeShikiji, ]; diff --git a/next.mdx.shiki.mjs b/next.mdx.shiki.mjs index 647a6daf750b6..cba9d0b05873e 100644 --- a/next.mdx.shiki.mjs +++ b/next.mdx.shiki.mjs @@ -4,7 +4,7 @@ import classNames from 'classnames'; import { toString } from 'hast-util-to-string'; import { getHighlighterCore } from 'shikiji/core'; import { getWasmInlined } from 'shikiji/wasm'; -import { visit } from 'unist-util-visit'; +import { SKIP, visit } from 'unist-util-visit'; import { LANGUAGES, DEFAULT_THEME } from './shiki.config.mjs'; @@ -19,8 +19,152 @@ const memoizedShikiji = await getHighlighterCore({ // to attribute the current language of the <pre> element const languagePrefix = 'language-'; +/** + * Retrieve the value for the given meta key. + * + * @example - Returns "CommonJS" + * getMetaParameter('displayName="CommonJS"', 'displayName'); + * + * @param {any} meta - The meta parameter. + * @param {string} key - The key to retrieve the value. + * + * @return {string | undefined} - The value related to the given key. + */ +function getMetaParameter(meta, key) { + if (typeof meta !== 'string') { + return; + } + + const matches = meta.match(new RegExp(`${key}="(?<parameter>[^"]*)"`)); + const parameter = matches?.groups.parameter; + + return parameter !== undefined && parameter.length > 0 + ? parameter + : undefined; +} + +/** + * @typedef {import('unist').Node} Node + * @property {string} tagName + * @property {Node[]} children + */ + +/** + * Checks if the given node is a valid code element. + * + * @param {Node} node - The node to be verified. + * + * @return {boolean} - True when it is a valid code element, false otherwise. + */ +function isCodeBlock(node) { + return node?.tagName === 'pre' && node?.children[0].tagName === 'code'; +} + +/** + * Retrieves a list indicating the starting, and ending indexes of sequential + * code elements. + * + * @param {Node} tree - The current MDX resolved content. + * + * @return {{start: number, end: number}[]} - The list containing every range of + * sequential code elements. + */ +function getCodeTabsRange(tree) { + const rangeMap = {}; + let start = null; + + visit(tree, 'element', (node, index, parent) => { + // Adding 2 since there is one text node between every element + const next = index + 2; + + if (isCodeBlock(node) && isCodeBlock(parent?.children[next])) { + start ??= index; + rangeMap[start] = next; + + // Prevent visiting the code block children + return SKIP; + } + + // End of sequential code elements, reset the start for the next range + start = null; + }); + + return Object.entries(rangeMap).map(([start, end]) => ({ + start: Number(start), + end: Number(end), + })); +} + export default function rehypeShikiji() { return async function (tree) { + // Retrieve all sequential code boxes to transform + const ranges = getCodeTabsRange(tree); + + if (ranges.length > 0) { + // Make a mutable clone without reference + const children = [...tree.children]; + + for (const range of ranges) { + // Simple tree containing the sequential code boxes among text nodes + const slicedTree = { + type: 'root', + children: tree.children.slice(range.start, range.end + 1), + }; + + const languages = []; + const displayNames = []; + const codeTabsChildren = []; + + visit(slicedTree, 'element', node => { + const codeElement = node.children[0]; + + const displayName = getMetaParameter( + codeElement.data?.meta, + 'displayName' + ); + + // We should get the language name from the class name + if (codeElement.properties.className?.length) { + const className = codeElement.properties.className.join(' '); + const matches = className.match(/language-(?<language>.*)/); + + languages.push(matches?.groups.language ?? 'text'); + } + + // Map the display names of each variant for the CodeTab + displayNames.push(displayName?.replaceAll('|', '') ?? ''); + codeTabsChildren.push(node); + + // Prevent visiting the code block children + return SKIP; + }); + + // Each iteration reduces the `children` length, so it needs to be + // accounted in the following operations + const lengthOffset = tree.children.length - children.length; + const compensatedRange = { + start: range.start - lengthOffset, + end: range.end - lengthOffset, + }; + + const deleteCount = compensatedRange.end - compensatedRange.start + 1; + + // Replace the sequential code boxes with a code tabs element + children.splice(compensatedRange.start, deleteCount, { + type: 'element', + tagName: 'CodeTabs', + children: codeTabsChildren, + properties: { + languages: languages.join('|'), + displayNames: displayNames.join('|'), + }, + }); + } + + // Update the tree with the transformed children + Object.assign(tree, { children: children }); + } + visit(tree, 'element', (node, index, parent) => { // We only want to process <pre>...</pre> elements if (!parent || index == null || node.tagName !== 'pre') { @@ -77,6 +221,14 @@ export default function rehypeShikiji() { codeLanguage ); + const showCopyButton = getMetaParameter( + preElement.data?.meta, + 'showCopyButton' + ); + + // Adds a Copy Button to the CodeBox if requested as an additional parameter + children[0].properties.showCopyButton = showCopyButton === 'true'; + // Replaces the <pre> element with the updated one parent.children.splice(index, 1, ...children); }); diff --git a/next.mdx.use.mjs b/next.mdx.use.mjs index 8a773e434f18c..f7b30138a38c5 100644 --- a/next.mdx.use.mjs +++ b/next.mdx.use.mjs @@ -5,11 +5,13 @@ import DownloadReleasesTable from './components/Downloads/DownloadReleasesTable' import Banner from './components/Home/Banner'; import HomeDownloadButton from './components/Home/HomeDownloadButton'; import Link from './components/Link'; +import MDXCodeBox from './components/MDX/CodeBox'; +import MDXCodeTabs from './components/MDX/CodeTabs'; import WithNodeRelease from './components/withNodeRelease'; import { ENABLE_WEBSITE_REDESIGN } from './next.constants.mjs'; /** - * A full list of React Components that we want to passthrough to MDX + * A full list of React Components that we want to pass through to MDX * * @type {import('mdx/types').MDXComponents} */ @@ -17,6 +19,7 @@ export const mdxComponents = { WithNodeRelease, HomeDownloadButton, DownloadReleasesTable, + CodeTabs: MDXCodeTabs, Banner, }; @@ -32,4 +35,7 @@ export const htmlComponents = { blockquote: ENABLE_WEBSITE_REDESIGN ? Blockquote : ({ children }) => <div className="highlight-box">{children}</div>, + pre: ({ children, ...props }) => ( + <MDXCodeBox {...props}>{children}</MDXCodeBox> + ), }; diff --git a/package-lock.json b/package-lock.json index 952de186781ad..ace0a25e24a55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "nodejsorg", + "name": "nodejs.org", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/pages/en/blog/release/v18.19.0.md b/pages/en/blog/release/v18.19.0.md index c356ee7e5cc75..e48e490d05d8b 100644 --- a/pages/en/blog/release/v18.19.0.md +++ b/pages/en/blog/release/v18.19.0.md @@ -483,7 +483,7 @@ Documentation: https://nodejs.org/docs/v18.19.0/api/ ### SHASUMS -```text +``` -----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 diff --git a/pages/en/blog/release/v20.10.0.md b/pages/en/blog/release/v20.10.0.md index eb7aee99436fc..e421a01d422d8 100644 --- a/pages/en/blog/release/v20.10.0.md +++ b/pages/en/blog/release/v20.10.0.md @@ -349,7 +349,7 @@ Documentation: https://nodejs.org/docs/v20.10.0/api/ ### SHASUMS -```text +``` -----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 diff --git a/pages/en/blog/release/v20.6.0.md b/pages/en/blog/release/v20.6.0.md index 702d53228b012..b6c0f56ab4222 100644 --- a/pages/en/blog/release/v20.6.0.md +++ b/pages/en/blog/release/v20.6.0.md @@ -17,7 +17,7 @@ To initialize your Node.js application with predefined configurations, use the f For example, you can access the following environment variable using `process.env.PASSWORD` when your application is initialized: -```text +``` PASSWORD=nodejs ``` diff --git a/pages/en/blog/release/v20.9.0.md b/pages/en/blog/release/v20.9.0.md index b62b655b177bb..12e35f290c52b 100644 --- a/pages/en/blog/release/v20.9.0.md +++ b/pages/en/blog/release/v20.9.0.md @@ -43,7 +43,7 @@ Documentation: https://nodejs.org/docs/v20.9.0/api/ ### SHASUMS -```text +``` -----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 diff --git a/pages/en/blog/release/v21.0.0.md b/pages/en/blog/release/v21.0.0.md index 193149d389af7..f1aafacc5f926 100644 --- a/pages/en/blog/release/v21.0.0.md +++ b/pages/en/blog/release/v21.0.0.md @@ -274,7 +274,7 @@ Documentation: https://nodejs.org/docs/v21.0.0/api/ ### SHASUMS -```text +``` -----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 diff --git a/pages/en/blog/release/v21.1.0.md b/pages/en/blog/release/v21.1.0.md index f4c4df809000e..444653d5331f2 100644 --- a/pages/en/blog/release/v21.1.0.md +++ b/pages/en/blog/release/v21.1.0.md @@ -153,7 +153,7 @@ Documentation: https://nodejs.org/docs/v21.1.0/api/ ### SHASUMS -```text +``` -----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 diff --git a/pages/en/blog/release/v21.2.0.md b/pages/en/blog/release/v21.2.0.md index e7f47a6a05161..1ca11dc45bef5 100644 --- a/pages/en/blog/release/v21.2.0.md +++ b/pages/en/blog/release/v21.2.0.md @@ -191,7 +191,7 @@ Documentation: https://nodejs.org/docs/v21.2.0/api/ ### SHASUMS -```text +``` -----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 diff --git a/pages/en/blog/release/v21.3.0.md b/pages/en/blog/release/v21.3.0.md index 59b2998faee7e..dc79b9b5a2d0d 100644 --- a/pages/en/blog/release/v21.3.0.md +++ b/pages/en/blog/release/v21.3.0.md @@ -202,7 +202,7 @@ Documentation: https://nodejs.org/docs/v21.3.0/api/ ### SHASUMS -```text +``` -----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 diff --git a/pages/en/blog/release/v21.4.0.md b/pages/en/blog/release/v21.4.0.md index fb63b43fa7063..cf990af9f16d2 100644 --- a/pages/en/blog/release/v21.4.0.md +++ b/pages/en/blog/release/v21.4.0.md @@ -77,7 +77,7 @@ Documentation: https://nodejs.org/docs/v21.4.0/api/ ### SHASUMS -```text +``` -----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 diff --git a/pages/en/blog/release/v21.5.0.md b/pages/en/blog/release/v21.5.0.md index 5cc4d2d01e62a..f5e36c1af0645 100644 --- a/pages/en/blog/release/v21.5.0.md +++ b/pages/en/blog/release/v21.5.0.md @@ -109,7 +109,7 @@ Documentation: https://nodejs.org/docs/v21.5.0/api/ ### SHASUMS -```text +``` -----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 diff --git a/providers/notificationProvider.tsx b/providers/notificationProvider.tsx index 157caf02f48e7..5b7e9590950f0 100644 --- a/providers/notificationProvider.tsx +++ b/providers/notificationProvider.tsx @@ -42,6 +42,7 @@ export const NotificationProvider: FC<PropsWithChildren<NotificationProps>> = ({ {children} <Toast.Viewport className={viewportClassName} /> + {notification && ( <Notification duration={notification.duration}> {notification.message} diff --git a/scripts/release-post/template.hbs b/scripts/release-post/template.hbs index b0fc82c628716..9dbc4a4d2c3e1 100644 --- a/scripts/release-post/template.hbs +++ b/scripts/release-post/template.hbs @@ -16,6 +16,6 @@ Documentation: https://nodejs.org/docs/v{{version}}/api/ ### SHASUMS -```text +``` {{shasums}} ``` diff --git a/shiki.config.mjs b/shiki.config.mjs index d534f0bbb36ff..62cda808b4ff3 100644 --- a/shiki.config.mjs +++ b/shiki.config.mjs @@ -20,34 +20,41 @@ export const LANGUAGES = [ ...javaScriptLanguage[0], scopeName: 'source.js', aliases: ['mjs', 'cjs', 'js'], + displayName: 'JavaScript', }, { ...jsonLanguage[0], scopeName: 'source.json', + displayName: 'JSON', }, { ...typeScriptLanguage[0], scopeName: 'source.ts', aliases: ['ts'], + displayName: 'TypeScript', }, { ...shellScriptLanguage[0], scopeName: 'source.shell', aliases: ['bash', 'sh', 'shell', 'zsh'], + displayName: 'Bash', }, { ...shellSessionLanguage[0], scopeName: 'text.shell-session', aliases: ['console'], + displayName: 'Bash', }, { ...dockerLanguage[0], scopeName: 'source.dockerfile', aliases: ['dockerfile'], + displayName: 'Dockerfile', }, { ...diffLanguage[0], scopeName: 'source.diff', + displayName: 'Diff', }, ]; diff --git a/styles/old/index.css b/styles/old/index.css index 5ce154497f3a4..db404b51c1820 100644 --- a/styles/old/index.css +++ b/styles/old/index.css @@ -112,14 +112,6 @@ a:hover .color-lightgray { } } -.shiki .line { - min-height: 1rem; - - &:last-child { - min-height: initial; - } -} - @media screen and (max-width: 1002px) { #main { article { diff --git a/util/getLanguageDisplayName.ts b/util/getLanguageDisplayName.ts new file mode 100644 index 0000000000000..5b1f167ab7092 --- /dev/null +++ b/util/getLanguageDisplayName.ts @@ -0,0 +1,11 @@ +import { LANGUAGES } from '@/shiki.config.mjs'; + +export const getLanguageDisplayName = (language: string): string => { + const languageByIdOrAlias = LANGUAGES.find( + ({ name, aliases }) => + name.toLowerCase() === language.toLowerCase() || + (aliases !== undefined && aliases.includes(language.toLowerCase())) + ); + + return languageByIdOrAlias?.displayName ?? language; +};