From aa0d9d6fd068bdc3ddd9b9c6ffc5fbfe59bba0a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Vitor?= Date: Thu, 4 Jan 2024 08:04:39 -0300 Subject: [PATCH] feat: add CodeBox component and code tabs plugin (#6038) Co-authored-by: Claudio Wunder Co-authored-by: Caner Akdas --- .storybook/main.ts | 2 +- components/Common/CodeBox/index.module.css | 88 ++++++++++ components/Common/CodeBox/index.stories.tsx | 38 +++++ components/Common/CodeBox/index.tsx | 112 +++++++++++++ components/Common/CodeTabs/index.module.css | 51 ++++++ components/Common/CodeTabs/index.stories.tsx | 75 +++++++++ components/Common/CodeTabs/index.tsx | 41 +++++ .../Common/Tabs/__tests__/index.test.mjs | 48 +++--- components/Common/Tabs/index.module.css | 14 ++ components/Common/Tabs/index.stories.tsx | 64 ++++---- components/Common/Tabs/index.tsx | 29 ++-- .../Downloads/ChangelogModal/index.module.css | 5 +- components/MDX/CodeBox/index.stories.tsx | 43 +++++ components/MDX/CodeBox/index.tsx | 26 +++ components/MDX/CodeTabs/index.stories.tsx | 52 ++++++ components/MDX/CodeTabs/index.tsx | 42 +++++ components/SideNavigation.tsx | 2 +- i18n/locales/en.json | 1 + layouts/New/Base.tsx | 8 +- next.fonts.ts | 2 +- next.mdx.mjs | 3 +- next.mdx.shiki.mjs | 154 +++++++++++++++++- next.mdx.use.mjs | 8 +- package-lock.json | 2 +- pages/en/blog/release/v18.19.0.md | 2 +- pages/en/blog/release/v20.10.0.md | 2 +- pages/en/blog/release/v20.6.0.md | 2 +- pages/en/blog/release/v20.9.0.md | 2 +- pages/en/blog/release/v21.0.0.md | 2 +- pages/en/blog/release/v21.1.0.md | 2 +- pages/en/blog/release/v21.2.0.md | 2 +- pages/en/blog/release/v21.3.0.md | 2 +- pages/en/blog/release/v21.4.0.md | 2 +- pages/en/blog/release/v21.5.0.md | 2 +- providers/notificationProvider.tsx | 1 + scripts/release-post/template.hbs | 2 +- shiki.config.mjs | 7 + styles/old/index.css | 8 - util/getLanguageDisplayName.ts | 11 ++ 39 files changed, 866 insertions(+), 93 deletions(-) create mode 100644 components/Common/CodeBox/index.module.css create mode 100644 components/Common/CodeBox/index.stories.tsx create mode 100644 components/Common/CodeBox/index.tsx create mode 100644 components/Common/CodeTabs/index.module.css create mode 100644 components/Common/CodeTabs/index.stories.tsx create mode 100644 components/Common/CodeTabs/index.tsx create mode 100644 components/MDX/CodeBox/index.stories.tsx create mode 100644 components/MDX/CodeBox/index.tsx create mode 100644 components/MDX/CodeTabs/index.stories.tsx create mode 100644 components/MDX/CodeTabs/index.tsx create mode 100644 util/getLanguageDisplayName.ts 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; +type Meta = MetaObj; + +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: {content}, + }, +}; + +export const WithCopyButton: Story = { + args: { + language: 'JavaScript (CJS)', + showCopyButton: true, + children: {content}, + }, +}; + +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 ( + + {lines.flatMap((line, lineIndex) => { + const columns = line.split(' '); + + return [ + + {columns.map((column, columnIndex) => ( + + {column} + {columnIndex < columns.length - 1 && } + + ))} + , + // Add a break line so the text content is formatted correctly + // when copying to clipboard + '\n', + ]; + })} + + ); +}; + +type CodeBoxProps = { language: string; showCopyButton?: boolean }; + +const CodeBox: FC> = ({ + 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(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: ( +
+ + {t('components.common.codebox.copied')} +
+ ), + }); + } + }; + + return ( +
+
+        {transformCode(children)}
+      
+ + {language && ( +
+ {language} + + {showCopyButton && ( + + )} +
+ )} +
+ ); +}; + +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; +type Meta = MetaObj; + +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 = () => ( + <> + + + {mjsContent} + + + + + {cjsContent} + + + +); + +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: , + 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, + 'tabs' | 'onValueChange' | 'defaultValue' +> & { + linkUrl?: string; + linkText?: string; +}; + +const CodeTabs: FC> = ({ + children, + linkUrl, + linkText, + ...props +}) => ( + + {linkText} + + + ) + } + > + {children} + +); + +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( - + const Sut = ({ addons }) => { + const tabs = [ + { key: 'package', label: 'Package Manager' }, + { key: 'prebuilt', label: 'Prebuilt Installer' }, + { key: 'source', label: 'Source Code' }, + ]; + + return ( + Package Manager @@ -25,29 +25,27 @@ describe('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(); - 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(); - 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(addon} />); - 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; type Meta = MetaObj; +const defaultArgs: ComponentProps = { + defaultValue: 'prebuilt', + tabs: [ + { + key: 'package', + label: 'Package Manager', + }, + { + key: 'prebuilt', + label: 'Prebuilt Installer', + }, + { + key: 'source', + label: 'Source Code', + }, + ], + children: ( + <> + + Package Manager + + + Prebuilt Installer + + Source Code + + ), +}; + 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: ( - <> - - Package Manager - - - Prebuilt Installer - - - Source Code - - - ), + ...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> = ({ tabs, + addons, headerClassName, children, ...props }) => ( - {tabs.map(tab => ( - - {tab.label} - - ))} +
+ {tabs.map(tab => ( + + {tab.label} + + ))} +
+ {addons != null &&
{addons}
}
{children}
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; +type Meta = MetaObj; + +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: }; + }, + ], +} 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> = ({ + children: code, + className, + showCopyButton, +}) => { + const matches = className?.match(/language-(?.*)/); + const language = matches?.groups?.language ?? ''; + + return ( + + {code} + + ); +}; + +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; +type Meta = MetaObj; + +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: }; + }, + ], +} 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; + languages: string; + displayNames?: string; +}; + +const MDXCodeTabs: FC = ({ + 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 ( + + {languages.map((language, index) => ( + + {codes[index]} + + ))} + + ); +}; + +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 = ({ const mapItems = (items: ReturnType) => { return items.map(([, { link, label, items }]) => ( -
  • +
  • {link ? {label} : label} {items && items.length > 0 &&
      {mapItems(items)}
    } 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 = ({ children }) => ( -
    {children}
    + +
    {children}
    +
    ); 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
     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}="(?[^"]*)"`));
    +  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-(?.*)/);
    +
    +            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 
    ...
    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
     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 }) => 
    {children}
    , + pre: ({ children, ...props }) => ( + {children} + ), }; 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> = ({ {children} + {notification && ( {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; +};