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; +}