diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index 153af7e3493ed..bb3cd3682e271 100644
--- a/.storybook/preview.tsx
+++ b/.storybook/preview.tsx
@@ -23,6 +23,7 @@ const preview: Preview = {
basePath: '',
},
},
+ backgrounds: { disable: true },
},
};
diff --git a/components/Article/Codebox/__snapshots__/index.stories.tsx.snap b/components/Article/Codebox/__snapshots__/index.stories.tsx.snap
new file mode 100644
index 0000000000000..decf2593ea9e9
--- /dev/null
+++ b/components/Article/Codebox/__snapshots__/index.stories.tsx.snap
@@ -0,0 +1,83 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Article/Codebox Default smoke-test 1`] = `
+
+
+
+
+
+
+
+
+
+ const
+
+ a
+
+ =
+
+
+ 1
+
+
+ ;
+
+
+
+`;
+
+exports[`Article/Codebox MultiLang smoke-test 1`] = `
+
+
+
+
+
+
+
+
+
+
+ const
+
+ http
+
+ =
+
+
+ require
+
+
+ (
+
+
+ 'http'
+
+
+ )
+
+
+ ;
+
+
+
+`;
diff --git a/components/Article/Codebox/__tests__/__snapshots__/index.test.tsx.snap b/components/Article/Codebox/__tests__/__snapshots__/index.test.tsx.snap
new file mode 100644
index 0000000000000..7783af5875145
--- /dev/null
+++ b/components/Article/Codebox/__tests__/__snapshots__/index.test.tsx.snap
@@ -0,0 +1,140 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Codebox component (multiple langs) switch between languages 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+ const
+
+ http
+
+ =
+
+
+
+ require
+
+
+ (
+
+
+ 'http'
+
+
+ )
+
+
+ ;
+
+
+
+
+
+
+`;
+
+exports[`Codebox component (multiple langs) switch between languages 2`] = `
+
+
+
+
+
+
+
+
+
+
+
+ import
+
+ http
+
+ from
+
+
+
+ 'http'
+
+
+ ;
+
+
+
+
+`;
diff --git a/components/Article/Codebox/__tests__/index.test.tsx b/components/Article/Codebox/__tests__/index.test.tsx
new file mode 100644
index 0000000000000..5ceda6c99f93e
--- /dev/null
+++ b/components/Article/Codebox/__tests__/index.test.tsx
@@ -0,0 +1,77 @@
+import userEvent from '@testing-library/user-event';
+import { render, screen } from '@testing-library/react';
+import { IntlProvider } from 'react-intl';
+
+import Codebox, { replaceLabelLanguages, replaceLanguages } from '../index';
+
+jest.mock('isomorphic-dompurify', () => {
+ return {
+ sanitize: jest.fn().mockImplementation(source => source),
+ };
+});
+
+describe('Replacer tests', (): void => {
+ it('replaceLabelLanguages', (): void => {
+ expect(replaceLabelLanguages('language-console')).toBe('language-bash');
+ });
+
+ it('replaceLanguages', (): void => {
+ expect(replaceLanguages('language-mjs')).toBe('language-js');
+ expect(replaceLanguages('language-cjs')).toBe('language-js');
+ expect(replaceLanguages('language-javascript')).toBe('language-js');
+ expect(replaceLanguages('language-console')).toBe('language-bash');
+ expect(replaceLanguages('language-shell')).toBe('language-bash');
+ });
+});
+
+describe('Codebox component (one lang)', (): void => {
+ const code = 'const a = 1;';
+
+ it('should copy content', async () => {
+ const user = userEvent.setup();
+
+ render(
+ {}}>
+
+ {code}
+
+
+ );
+
+ const navigatorClipboardWriteTextSpy = jest.spyOn(
+ navigator.clipboard,
+ 'writeText'
+ );
+
+ const buttonElement = screen.getByText('components.codeBox.copy');
+ await user.click(buttonElement);
+
+ expect(navigatorClipboardWriteTextSpy).toHaveBeenCalledTimes(1);
+ expect(navigatorClipboardWriteTextSpy).toHaveBeenCalledWith(code);
+ });
+});
+
+describe('Codebox component (multiple langs)', (): void => {
+ const code = `const http = require('http');
+--------------
+import http from 'http';`;
+
+ it('switch between languages', async () => {
+ const user = userEvent.setup();
+
+ const { container } = render(
+ {}}>
+
+ {code}
+
+
+ );
+
+ expect(container).toMatchSnapshot();
+
+ const buttonElement = await screen.findByText('mjs');
+ await user.click(buttonElement);
+
+ expect(container).toMatchSnapshot();
+ });
+});
diff --git a/components/Article/Codebox/dark.module.scss b/components/Article/Codebox/dark.module.scss
new file mode 100644
index 0000000000000..397cd95264bf7
--- /dev/null
+++ b/components/Article/Codebox/dark.module.scss
@@ -0,0 +1,90 @@
+@mixin darkStyles {
+ background: var(--black9);
+ color: var(--black2);
+
+ :global .token {
+ &.comment,
+ &.prolog,
+ &.doctype,
+ &.cdata {
+ color: #8292a2;
+ }
+
+ &.operator,
+ &.punctuation {
+ color: var(--black2);
+ }
+
+ &.namespace {
+ opacity: 0.7;
+ }
+
+ &.property,
+ &.tag,
+ &.constant,
+ &.symbol,
+ &.deleted {
+ color: #f92672;
+ }
+
+ &.boolean {
+ color: #ae81ff;
+ }
+
+ &.selector,
+ &.attr-name,
+ &.char,
+ &.builtin,
+ &.inserted {
+ color: #a6e22e;
+ }
+
+ &.entity,
+ &.url,
+ .language-css &.string,
+ .style &.string,
+ &.variable {
+ color: #f8f8f2;
+ }
+
+ &.atrule,
+ &.attr-value,
+ &.class-name {
+ color: #e6db74;
+ }
+
+ &.function {
+ color: var(--warning3);
+ }
+
+ &.string {
+ color: var(--brand3);
+ }
+
+ &.keyword {
+ color: var(--info3);
+ }
+
+ &.number {
+ color: var(--purple3);
+ }
+
+ &.regex,
+ &.important {
+ color: #fd971f;
+ }
+
+ &.important,
+ &.bold {
+ font-weight: var(--font-weight-bold);
+ }
+
+ &.italic {
+ font-style: italic;
+ }
+
+ &.entity {
+ cursor: help;
+ }
+ }
+}
diff --git a/components/Article/Codebox/index.module.scss b/components/Article/Codebox/index.module.scss
new file mode 100644
index 0000000000000..cdf343574c34e
--- /dev/null
+++ b/components/Article/Codebox/index.module.scss
@@ -0,0 +1,99 @@
+@import './light.module';
+@import './dark.module';
+@import '../../../styles/code';
+
+.pre {
+ @extend %codeBaseStyles;
+
+ .top {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+
+ .lang,
+ .copy {
+ align-items: center;
+ cursor: pointer;
+ display: inherit;
+ font-size: var(--font-size-code);
+ height: 23px;
+ justify-content: center;
+ width: 86px;
+ }
+
+ .langBox {
+ background-color: var(--black4);
+ border-radius: 0 0 0.3rem 0;
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+
+ .lang {
+ background-color: var(--black4);
+ border-width: 0;
+ color: var(--black9);
+ padding: 0 16px;
+ width: max-content;
+
+ &:last-of-type {
+ border-radius: 0 0 0.3rem 0;
+ }
+
+ &:hover {
+ background-color: var(--black5);
+ }
+ }
+
+ .lang.selected {
+ font-weight: 600;
+ }
+ }
+
+ .copy {
+ background-color: var(--black9);
+ border-radius: 0 0 0 0.3rem;
+ border-width: 0;
+ color: white;
+
+ &:hover {
+ background-color: var(--black8);
+ }
+
+ i {
+ padding: 0;
+ }
+ }
+ }
+
+ .content {
+ margin: 1em;
+ }
+}
+
+[data-theme='light'] {
+ .pre {
+ @include lightStyles;
+ }
+}
+
+[data-theme='dark'] {
+ .pre {
+ @include darkStyles;
+
+ .top {
+ span {
+ background-color: var(--black3);
+ color: var(--black9);
+ }
+
+ .copy {
+ background-color: var(--brand8);
+ color: white;
+
+ &:hover {
+ background-color: var(--brand6);
+ }
+ }
+ }
+ }
+}
diff --git a/components/Article/Codebox/index.stories.tsx b/components/Article/Codebox/index.stories.tsx
new file mode 100644
index 0000000000000..94b48832d64bf
--- /dev/null
+++ b/components/Article/Codebox/index.stories.tsx
@@ -0,0 +1,37 @@
+import Codebox from './index';
+import type { Meta as MetaObj, StoryObj } from '@storybook/react';
+import type { FC } from 'react';
+
+type DecoratedCodeBoxProps = { language: string[]; code: string[] };
+
+const DecoratedCodeBox: FC = ({ language, code }) => (
+
+ {code.join('--------------\n')}
+
+);
+
+type Story = StoryObj;
+type Meta = MetaObj;
+
+const singleLangCode = ['const a = 1;'];
+
+export const Default: Story = {
+ args: {
+ language: ['language-js'],
+ code: singleLangCode,
+ },
+};
+
+const multiLangCode = [
+ "const http = require('http');",
+ "import http from 'http';",
+];
+
+export const MultiLang: Story = {
+ args: {
+ language: ['language-cjs', 'language-mjs'],
+ code: multiLangCode,
+ },
+};
+
+export default { component: DecoratedCodeBox } as Meta;
diff --git a/components/Article/Codebox/index.tsx b/components/Article/Codebox/index.tsx
new file mode 100644
index 0000000000000..e73f55ded326f
--- /dev/null
+++ b/components/Article/Codebox/index.tsx
@@ -0,0 +1,83 @@
+import { useEffect, useState } from 'react';
+import { FormattedMessage } from 'react-intl';
+import { highlight, languages } from 'prismjs';
+import { sanitize } from 'isomorphic-dompurify';
+import classnames from 'classnames';
+import styles from './index.module.scss';
+import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard';
+import type { FC, PropsWithChildren, ReactElement, MouseEvent } from 'react';
+
+type CodeBoxProps = {
+ children: ReactElement>;
+};
+
+export const replaceLabelLanguages = (language: string) =>
+ language.replace(/console/i, 'bash');
+
+export const replaceLanguages = (language: string) =>
+ language
+ .replace(/mjs|cjs|javascript/i, 'js')
+ .replace(/console|shell/i, 'bash');
+
+const Codebox: FC = ({ children: { props } }) => {
+ const [parsedCode, setParsedCode] = useState('');
+ const [copied, copyText] = useCopyToClipboard();
+ const [langIndex, setLangIndex] = useState(0);
+
+ const className = props.className || 'text';
+
+ const languageOptions = className
+ .split('|')
+ .map(language => language.split('language-')[1]);
+
+ const language = languageOptions[langIndex];
+
+ const codeArray = props.children
+ ? props.children.toString().split('--------------\n')
+ : [''];
+
+ const handleCopyCode = (event: MouseEvent) => {
+ event.preventDefault();
+ copyText(codeArray[langIndex]);
+ };
+
+ useEffect(() => {
+ const parsedLanguage = replaceLanguages(language);
+ const prismLanguage = languages[parsedLanguage] || languages.text;
+
+ setParsedCode(
+ sanitize(highlight(codeArray[langIndex], prismLanguage, parsedLanguage))
+ );
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [langIndex]);
+
+ return (
+
+
+
+ {languageOptions.map((lang, index) => (
+
+ ))}
+
+
+
+
+
+ );
+};
+
+export default Codebox;
diff --git a/components/Article/Codebox/light.module.scss b/components/Article/Codebox/light.module.scss
new file mode 100644
index 0000000000000..8b35d68503fa0
--- /dev/null
+++ b/components/Article/Codebox/light.module.scss
@@ -0,0 +1,76 @@
+@mixin lightStyles {
+ background: var(--black2);
+ color: black;
+
+ :global .token {
+ &.comment,
+ &.prolog,
+ &.doctype,
+ &.cdata {
+ color: slategray;
+ }
+
+ &.namespace {
+ opacity: 0.7;
+ }
+
+ &.property,
+ &.tag,
+ &.boolean,
+ &.number,
+ &.constant,
+ &.symbol,
+ &.deleted {
+ color: #905;
+ }
+
+ &.selector,
+ &.attr-name,
+ &.char,
+ &.builtin,
+ &.inserted {
+ color: #690;
+ }
+
+ &.entity,
+ &.url {
+ background: hsla(0, 0%, 100%, 0.5);
+ color: #9a6e3a;
+ }
+
+ &.atrule,
+ &.attr-value,
+ &.keyword {
+ color: #07a;
+ }
+
+ &.function,
+ &.class-name {
+ color: #dd4a68;
+ }
+
+ &.regex,
+ &.important,
+ &.variable {
+ color: #e90;
+ }
+
+ &.important,
+ &.bold {
+ font-weight: var(--font-weight-vold);
+ }
+ &.italic {
+ font-style: italic;
+ }
+
+ &.entity {
+ cursor: help;
+ }
+
+ &.punctuation,
+ &.operator,
+ &.string {
+ background-color: var(--black2);
+ }
+ }
+}
diff --git a/components/Article/InlineCode/__snapshots__/index.stories.tsx.snap b/components/Article/InlineCode/__snapshots__/index.stories.tsx.snap
new file mode 100644
index 0000000000000..734dcbd2952be
--- /dev/null
+++ b/components/Article/InlineCode/__snapshots__/index.stories.tsx.snap
@@ -0,0 +1,9 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Article/InlineCode Default smoke-test 1`] = `
+
+
+ const a = 1;
+
+
+`;
diff --git a/components/Article/InlineCode/index.module.scss b/components/Article/InlineCode/index.module.scss
new file mode 100644
index 0000000000000..c674ce45deba1
--- /dev/null
+++ b/components/Article/InlineCode/index.module.scss
@@ -0,0 +1,36 @@
+@import '../../../styles/code';
+
+.code {
+ @extend %codeBaseStyles;
+
+ font-weight: var(--font-weight-light);
+ padding: 0 6px;
+
+ white-space: break-spaces;
+}
+
+[data-theme='light'] {
+ .code {
+ background-color: var(--black2);
+ color: var(--black9);
+ }
+
+ a .code {
+ background-color: transparent;
+ color: var(--color-text-accent);
+ padding: 0;
+ }
+}
+
+[data-theme='dark'] {
+ .code {
+ background-color: var(--black9);
+ color: var(--black2);
+ }
+
+ a .code {
+ background-color: transparent;
+ color: var(--color-text-accent);
+ padding: 0;
+ }
+}
diff --git a/components/Article/InlineCode/index.stories.tsx b/components/Article/InlineCode/index.stories.tsx
new file mode 100644
index 0000000000000..37d8503e65a52
--- /dev/null
+++ b/components/Article/InlineCode/index.stories.tsx
@@ -0,0 +1,20 @@
+import InlineCode from './index';
+import type { FC } from 'react';
+import type { Meta as MetaObj, StoryObj } from '@storybook/react';
+
+type DecoratedInlineCodeProps = { code: string };
+
+const DecoratedInlineCode: FC = ({ code }) => (
+
+ {code}
+
+);
+
+type Story = StoryObj;
+type Meta = MetaObj;
+
+const code = 'const a = 1;';
+
+export const Default: Story = { args: { code } };
+
+export default { component: DecoratedInlineCode } as Meta;
diff --git a/components/Article/InlineCode/index.tsx b/components/Article/InlineCode/index.tsx
new file mode 100644
index 0000000000000..a3f79197169a9
--- /dev/null
+++ b/components/Article/InlineCode/index.tsx
@@ -0,0 +1,8 @@
+import styles from './index.module.scss';
+import type { FC, PropsWithChildren } from 'react';
+
+const InlineCode: FC = ({ children }) => (
+ {children}
+);
+
+export default InlineCode;
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index a0e1daf85a793..aa5d0274b1df2 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -59,6 +59,7 @@
"components.home.nodeFeatures.everywhere.title": "Everywhere",
"components.home.nodeFeatures.everywhere.description": "Node.js has been adapted to work in a wide variety of places",
"components.common.shellBox.copy": "{copied, select, true {copied}other {copy}}",
+ "components.codeBox.copy": "{copied, select, true {copied}other {copy}}",
"components.api.stability": "Stability: {level} - ",
"components.api.jsonLink.title": "View as JSON"
}
diff --git a/package-lock.json b/package-lock.json
index c81ccf128c080..4f587b2815cdf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,6 +26,7 @@
"next-themes": "^0.2.1",
"nextra": "^2.4.2",
"node-version-data": "^1.1.0",
+ "prismjs": "^1.29.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.8.0",
@@ -54,6 +55,7 @@
"@types/jest": "^29.5.1",
"@types/mdx": "^2.0.5",
"@types/node": "^18.16.12",
+ "@types/prismjs": "^1.26.0",
"@types/react": "^18.2.2",
"@types/react-dom": "^18.2.3",
"@types/semver": "^7.3.13",
@@ -8388,6 +8390,12 @@
"integrity": "sha512-VjID5MJb1eGKthz2qUerWT8+R4b9N+CHvGCzg9fn4kWZgaF9AhdYikQio3R7wV8YY1NsQKPaCwKz1Yff+aHNUQ==",
"dev": true
},
+ "node_modules/@types/prismjs": {
+ "version": "1.26.0",
+ "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.0.tgz",
+ "integrity": "sha512-ZTaqn/qSqUuAq1YwvOFQfVW1AR/oQJlLSZVustdjwI+GZ8kr0MSHBj0tsXPW1EqHubx50gtBEjbPGsdZwQwCjQ==",
+ "dev": true
+ },
"node_modules/@types/prop-types": {
"version": "15.7.5",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
@@ -22994,6 +23002,14 @@
"node": ">= 0.8"
}
},
+ "node_modules/prismjs": {
+ "version": "1.29.0",
+ "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz",
+ "integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
diff --git a/package.json b/package.json
index 12877ff976eb7..9bcf20b7f2247 100644
--- a/package.json
+++ b/package.json
@@ -53,6 +53,7 @@
"next-themes": "^0.2.1",
"nextra": "^2.4.2",
"node-version-data": "^1.1.0",
+ "prismjs": "^1.29.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.8.0",
@@ -81,6 +82,7 @@
"@types/jest": "^29.5.1",
"@types/mdx": "^2.0.5",
"@types/node": "^18.16.12",
+ "@types/prismjs": "^1.26.0",
"@types/react": "^18.2.2",
"@types/react-dom": "^18.2.3",
"@types/semver": "^7.3.13",
diff --git a/styles/code.scss b/styles/code.scss
new file mode 100644
index 0000000000000..e526cedccb8c8
--- /dev/null
+++ b/styles/code.scss
@@ -0,0 +1,23 @@
+%codeBaseStyles {
+ border-radius: 0.3rem;
+ font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
+ font-size: 1em;
+
+ -webkit-hyphens: none;
+ -moz-hyphens: none;
+ -ms-hyphens: none;
+ hyphens: none;
+ line-height: 1.5;
+ margin: 0.5em 0 var(--space-24) 0;
+ overflow: auto;
+ padding: 0;
+
+ -moz-tab-size: 4;
+ -o-tab-size: 4;
+ tab-size: 4;
+ text-align: left;
+ white-space: pre;
+ word-break: normal;
+ word-spacing: normal;
+ word-wrap: normal;
+}