diff --git a/.babelrc b/.babelrc new file mode 100644 index 000000000..28c034bf8 --- /dev/null +++ b/.babelrc @@ -0,0 +1,20 @@ +{ + "presets": ["babel-preset-gatsby"], + "plugins": [ + [ + "babel-plugin-prismjs", + { + "languages": [ + "css", + "hcl", + "javascript", + "json", + "jsx", + "ruby", + "shell", + "sql" + ] + } + ] + ] +} diff --git a/COMPONENT_GUIDE.md b/COMPONENT_GUIDE.md index 689f731a4..ff4b68d8d 100644 --- a/COMPONENT_GUIDE.md +++ b/COMPONENT_GUIDE.md @@ -178,9 +178,9 @@ A step description > Note: keep in mind that a new line is necesary after an `img` tag to ensure proper rendering of subsequent text/markdown. -## Code Snippet +## Code blocks -Code Snippets are automatically formatted by three backticks. This is our preferred method to delineate code snippets, but it's worth noting that markdown will also consider any text that is indented 4 spaces (or 1 tab) to be a code block. +Code blocks are automatically formatted by three backticks. This is our preferred method to delineate code snippets, but it's worth noting that markdown will also consider any text that is indented 4 spaces (or 1 tab) to be a code block. ### Usage @@ -193,10 +193,10 @@ There are four props that can be supplied to a code snippet. ``` ```` -- `lineNumbers`: `true` or `false`. Will show line numbers of the left side of the code, defaults to `true`. +- `lineNumbers`: `true` or `false`. Will show line numbers of the left side of the code, defaults to `false`. ````md - ```jsx lineNumbers=false + ```jsx lineNumbers=true ``` ```` diff --git a/package-lock.json b/package-lock.json index 748b81c30..b19ada87f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5375,6 +5375,12 @@ "resolve": "^1.12.0" } }, + "babel-plugin-prismjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-prismjs/-/babel-plugin-prismjs-2.0.1.tgz", + "integrity": "sha512-GqQGa3xX3Z2ft97oDbGvEFoxD8nKqb3ZVszrOc5H7icnEUA56BIjVYm86hfZZA82uuHLwTIfCXbEKzKG1BzKzg==", + "dev": true + }, "babel-plugin-remove-graphql-queries": { "version": "2.9.7", "resolved": "https://registry.npmjs.org/babel-plugin-remove-graphql-queries/-/babel-plugin-remove-graphql-queries-2.9.7.tgz", @@ -25276,6 +25282,11 @@ "fbjs": "^0.8.0", "gud": "^1.0.0" } + }, + "react-simple-code-editor": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/react-simple-code-editor/-/react-simple-code-editor-0.10.0.tgz", + "integrity": "sha512-bL5W5mAxSW6+cLwqqVWY47Silqgy2DKDTR4hDBrLrUqC5BXc29YVx17l2IZk5v36VcDEq1Bszu2oHm1qBwKqBA==" } } }, @@ -25415,9 +25426,9 @@ "integrity": "sha512-IgmcegOSi5SNX+2Snh1vqmF0Vg/CbkycU9XZbOHJlZ6kMzTmi3yc254oB1WCkgA7OQtIAoLmcSFuHTc/tlcqXg==" }, "react-simple-code-editor": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/react-simple-code-editor/-/react-simple-code-editor-0.10.0.tgz", - "integrity": "sha512-bL5W5mAxSW6+cLwqqVWY47Silqgy2DKDTR4hDBrLrUqC5BXc29YVx17l2IZk5v36VcDEq1Bszu2oHm1qBwKqBA==" + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/react-simple-code-editor/-/react-simple-code-editor-0.11.0.tgz", + "integrity": "sha512-xGfX7wAzspl113ocfKQAR8lWPhavGWHL3xSzNLeseDRHysT+jzRBi/ExdUqevSMos+7ZtdfeuBOXtgk9HTwsrw==" }, "react-test-renderer": { "version": "16.13.1", diff --git a/package.json b/package.json index c430e6b3e..ef662d2ee 100644 --- a/package.json +++ b/package.json @@ -38,12 +38,14 @@ "react-markdown": "^4.3.1", "react-middle-ellipsis": "^1.1.0", "react-shadow": "^18.1.2", + "react-simple-code-editor": "^0.11.0", "use-dark-mode": "^2.3.1" }, "devDependencies": { "@newrelic/eslint-plugin-newrelic": "^0.3.0", "@testing-library/react": "^10.0.4", "babel-jest": "^26.0.1", + "babel-plugin-prismjs": "^2.0.1", "babel-preset-gatsby": "^0.4.2", "eslint": "^6.8.0", "eslint-plugin-jsx-a11y": "^6.2.3", diff --git a/src/components/Button.js b/src/components/Button.js index a163b1c74..f3be56a0f 100644 --- a/src/components/Button.js +++ b/src/components/Button.js @@ -8,11 +8,12 @@ const Button = ({ children, className, variant, + size, ...props }) => ( {children} @@ -24,10 +25,15 @@ Button.VARIANT = { NORMAL: 'normal', }; +Button.SIZE = { + SMALL: 'small', +}; + Button.propTypes = { as: PropTypes.elementType, children: PropTypes.node, className: PropTypes.string, + size: PropTypes.oneOf(Object.values(Button.SIZE)), type: PropTypes.oneOf(['button', 'submit', 'reset']), variant: PropTypes.oneOf(Object.values(Button.VARIANT)).isRequired, }; diff --git a/src/components/Button.module.scss b/src/components/Button.module.scss index f537ab547..7b58812f0 100644 --- a/src/components/Button.module.scss +++ b/src/components/Button.module.scss @@ -46,3 +46,7 @@ color: var(--color-brand-400); } } + +.small { + font-size: 0.75rem; +} diff --git a/src/components/CodeBlock.js b/src/components/CodeBlock.js new file mode 100644 index 000000000..67dc67fb0 --- /dev/null +++ b/src/components/CodeBlock.js @@ -0,0 +1,113 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import Button from './Button'; +import CodeEditor from './CodeEditor'; +import CodeHighlight from './CodeHighlight'; +import FeatherIcon from './FeatherIcon'; +import MiddleEllipsis from 'react-middle-ellipsis'; +import { LiveError, LivePreview, LiveProvider } from 'react-live'; +import styles from './CodeBlock.module.scss'; +import useClipboard from '../hooks/useClipboard'; +import useFormattedCode from '../hooks/useFormattedCode'; + +const defaultComponents = { + Preview: LivePreview, +}; + +const CodeBlock = ({ + children, + components: componentOverrides = {}, + copyable, + live, + highlightedLines, + fileName, + language, + lineNumbers, + preview, + scope, + formatOptions, +}) => { + const components = { ...defaultComponents, ...componentOverrides }; + const formattedCode = useFormattedCode(children.trim(), formatOptions); + const [copied, copy] = useClipboard(); + const [code, setCode] = useState(formattedCode); + + useEffect(() => { + setCode(formattedCode); + }, [formattedCode]); + + return ( + + {preview && } +
+
+ {live ? ( + + ) : ( + + {code} + + )} +
+ + {(copyable || fileName) && ( +
+
+ {fileName && ( + + {fileName} + + )} +
+ +
+ )} +
+ {(live || preview) && } +
+ ); +}; + +CodeBlock.propTypes = { + fileName: PropTypes.string, + components: PropTypes.shape({ + Preview: PropTypes.elementType, + }), + copyable: PropTypes.bool, + children: PropTypes.string.isRequired, + formatOptions: PropTypes.object, + highlightedLines: PropTypes.string, + language: PropTypes.string, + lineNumbers: PropTypes.bool, + live: PropTypes.bool, + preview: PropTypes.bool, + scope: PropTypes.object, +}; + +CodeBlock.defaultProps = { + copyable: true, + lineNumbers: false, + live: false, + preview: false, +}; + +export default CodeBlock; diff --git a/src/components/CodeBlock.module.scss b/src/components/CodeBlock.module.scss new file mode 100644 index 000000000..a13a605eb --- /dev/null +++ b/src/components/CodeBlock.module.scss @@ -0,0 +1,69 @@ +.container { + background: var(--color-nord-0); + border-radius: 4px; + + :global(.light-mode) & { + background: var(--color-nord-6); + } + + &.withPreview { + border-top-left-radius: 0; + border-top-right-radius: 0; + } +} + +.scrollContainer { + max-height: 26em; + overflow: auto; +} + +.statusBar { + color: var(--color-nord-6); + display: flex; + justify-content: space-between; + align-items: center; + background: var(--color-nord-1); + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + padding: 0 1rem; + font-size: 0.75rem; + + :global(.light-mode) & { + color: var(--color-nord-0); + background: var(--color-nord-4); + } +} + +.fileName { + font-family: var(--code-font); + white-space: nowrap; + overflow: hidden; + padding-right: 0.5rem; +} + +.copyButton { + white-space: nowrap; +} + +.copyButtonIcon { + margin-right: 0.5rem; +} + +.liveError { + color: white; + background: var(--color-red-400); + padding: 0.5rem 1rem; + font-size: 0.75rem; + overflow: auto; + margin-top: 0.5rem; + border-radius: 2px; +} + +.preview { + padding: 2rem; + background: var(--color-white); + border: 1px solid var(--color-neutrals-100); + box-shadow: var(--boxshadow); + border-top-left-radius: 4px; + border-top-right-radius: 4px; +} diff --git a/src/components/CodeEditor.js b/src/components/CodeEditor.js new file mode 100644 index 000000000..5ec07a835 --- /dev/null +++ b/src/components/CodeEditor.js @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import Editor from 'react-simple-code-editor'; +import CodeHighlight from './CodeHighlight'; +import styles from './CodeEditor.module.scss'; + +const CodeEditor = ({ value, language, lineNumbers, onChange }) => { + const lineNumberWidth = value.trim().split('\n').length.toString().length; + + return ( + ( + + {code} + + )} + textareaClassName={cx({ [styles.lineNumbers]: lineNumbers })} + style={{ + fontFamily: 'var(--code-font)', + fontSize: '0.75rem', + '--line-number-width': `${lineNumberWidth}ch`, + }} + /> + ); +}; + +CodeEditor.propTypes = { + language: PropTypes.string.isRequired, + lineNumbers: PropTypes.bool, + onChange: PropTypes.func, + value: PropTypes.string.isRequired, +}; + +export default CodeEditor; diff --git a/src/components/CodeEditor.module.scss b/src/components/CodeEditor.module.scss new file mode 100644 index 000000000..f7add2e48 --- /dev/null +++ b/src/components/CodeEditor.module.scss @@ -0,0 +1,7 @@ +.editor { + padding: 0 !important; +} + +.lineNumbers { + padding-left: calc(2rem + var(--line-number-width)) !important; +} diff --git a/src/components/CodeHighlight.js b/src/components/CodeHighlight.js new file mode 100644 index 000000000..c1265a37a --- /dev/null +++ b/src/components/CodeHighlight.js @@ -0,0 +1,94 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import Highlight from 'prism-react-renderer'; +import Prism from 'prismjs'; +import styles from './CodeHighlight.module.scss'; +import { partition, range } from '../utils/array'; + +const CodeHighlight = ({ + className, + children, + highlightedLines: highlightedLineString, + language, + lineNumbers, + wrap, +}) => { + const highlightedLines = getHighlightedLines(highlightedLineString); + + return ( + + {({ tokens, getLineProps, getTokenProps }) => { + const lineNumberWidth = String(tokens.length).length; + + return ( +
+            
+              {tokens.map((line, idx) => (
+                // eslint-disable-next-line react/jsx-key
+                
+ {lineNumbers && ( +
{idx + 1}
+ )} +
+ {line.map((token, key) => ( + // eslint-disable-next-line react/jsx-key + + ))} +
+
+ ))} +
+
+ ); + }} +
+ ); +}; + +const getHighlightedLines = (highlightedLineString) => { + if (!highlightedLineString) { + return new Set(); + } + + const groups = highlightedLineString.split(',').map((str) => str.trim()); + const [ranges, lines] = partition(groups, (group) => group.includes('-')); + + const lineRanges = ranges + .map((range) => range.split('-').map(Number)) + .reduce((acc, [a, b]) => acc.concat(range(a, b)), []); + + return new Set(lines.map(Number).concat(lineRanges)); +}; + +CodeHighlight.propTypes = { + className: PropTypes.string, + children: PropTypes.string.isRequired, + highlightedLines: PropTypes.string, + language: PropTypes.string, + lineNumbers: PropTypes.bool, + wrap: PropTypes.bool, +}; + +CodeHighlight.defaultProps = { + wrap: false, +}; + +export default CodeHighlight; diff --git a/src/components/CodeHighlight.module.scss b/src/components/CodeHighlight.module.scss new file mode 100644 index 000000000..256caeeea --- /dev/null +++ b/src/components/CodeHighlight.module.scss @@ -0,0 +1,122 @@ +.container { + color: var(--color-nord-6); + font-family: var(--code-font); + font-size: 0.75rem; + display: block; + overflow: auto; + white-space: pre; + word-spacing: normal; + word-break: normal; + tab-size: 2; + hyphens: none; + text-shadow: none; + padding: 1rem; + + > code { + display: table; + width: 100%; + padding: 0 !important; + background: none !important; + } + + :global(.light-mode) & { + color: var(--color-nord-0); + background: var(--color-nord-6); + } +} + +.lineNumbers { + :global(.token-line) { + display: grid; + grid-template-columns: var(--line-number-width) 1fr; + grid-gap: 1rem; + } +} + +.lineNumber { + user-select: none; + color: var(--color-nord-3); + text-align: right; +} + +.wrap { + white-space: pre-wrap; +} + +.highlightLine { + background: var(--color-nord-2); + + :global(.light-mode) & { + background: var(--color-nord-5); + } +} + +:global { + .namespace { + opacity: 0.7; + } + + .token { + &.comment, + &.prolog, + &.doctype, + &.cdata { + color: var(--color-nord-3); + } + + &.tag, + &.class-name { + color: var(--color-nord-7); + } + + &.function { + color: var(--color-nord-8); + } + + &.punctuation, + &.operator, + &.keyword, + &.property, + &.entity, + &.atrule, + &.attr-value, + &.url { + color: var(--color-nord-9); + } + + &.regex, + &.important, + &.variable { + color: var(--color-nord-12); + } + + &.selector, + &.attr-name, + &.string, + &.char, + &.builtin, + &.inserted { + color: var(--color-nord-14); + } + + &.property, + &.boolean, + &.constant, + &.symbol, + &.deleted, + &.number { + color: var(--color-nord-15); + } + + &.important, + &.bold { + font-weight: bold; + } + &.italic { + font-style: italic; + } + &.entity { + cursor: help; + } + } +} diff --git a/src/components/CodeSnippet.js b/src/components/CodeSnippet.js deleted file mode 100644 index 9a58cec1f..000000000 --- a/src/components/CodeSnippet.js +++ /dev/null @@ -1,131 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import Button from './Button'; -import Highlight, { defaultProps } from 'prism-react-renderer'; -import lightTheme from 'prism-react-renderer/themes/github'; -import darkTheme from 'prism-react-renderer/themes/nightOwl'; -import MiddleEllipsis from 'react-middle-ellipsis'; -import FeatherIcon from './FeatherIcon'; -import styles from './CodeSnippet.module.scss'; -import useClipboard from '../hooks/useClipboard'; -import useFormattedCode from '../hooks/useFormattedCode'; -import Prism from 'prism-react-renderer/prism'; -import useDarkMode from 'use-dark-mode'; -import cx from 'classnames'; - -(typeof global !== 'undefined' ? global : window).Prism = Prism; - -require('prismjs/components/prism-ruby'); - -const CodeSnippet = ({ - children, - copy, - className, - lineNumbers, - fileName, - lineHighlight, -}) => { - const language = className.replace('language-', ''); - const formattedCode = useFormattedCode(children ?? ''); - const [copied, copyCode] = useClipboard(); - const darkMode = useDarkMode(); - const linesToHighlight = - lineHighlight && captureLinesToHighlight(lineHighlight); - - return ( -
-
- - {({ style, tokens, getLineProps, getTokenProps }) => ( -
-              
-                {tokens.map((line, i) => (
-                  
- {lineNumbers !== 'false' && ( - {i + 1} - )} - {line.map((token, key) => ( - - ))} -
- ))} -
-
- )} -
-
- {(copy !== 'false' || fileName) && ( -
-
- {fileName && ( - - {fileName} - - )} -
- {copy !== 'false' && ( - - )} -
- )} -
- ); -}; - -const captureLinesToHighlight = (str) => { - const range = (a, b) => [...Array(b + 1).keys()].slice(a); - - const groups = str.split(','); - - const singles = groups.filter((g) => !g.includes('-')).map(Number); - - const rangeSingles = groups - .filter((g) => g.includes('-')) - .map((range) => range.split('-').map(Number)) - .reduce((acc, [a, b]) => [...acc, ...range(a, b)], []); - - return [...new Set([...singles, ...rangeSingles])]; -}; - -CodeSnippet.propTypes = { - children: PropTypes.node.isRequired, - className: PropTypes.string, - copy: PropTypes.string, - fileName: PropTypes.string, - lineNumbers: PropTypes.string, - lineHighlight: PropTypes.string, -}; - -CodeSnippet.defaultProps = { - className: 'language-javascript', - copy: 'true', - lineNumbers: 'true', -}; - -export default CodeSnippet; diff --git a/src/components/CodeSnippet.module.scss b/src/components/CodeSnippet.module.scss deleted file mode 100644 index 5b4eefd32..000000000 --- a/src/components/CodeSnippet.module.scss +++ /dev/null @@ -1,63 +0,0 @@ -.container { - line-height: 1rem; - font-size: 0.75rem; - max-height: 26rem; - overflow: auto; -} - -.codeContainer { - box-sizing: border-box; - margin: 0; - height: 100%; - overflow-y: auto; - padding: 1rem; - - code { - display: block; - padding: 0; - background: none !important; - } -} - -.lineNumber { - display: inline-block; - width: 1.25rem; - text-align: right; - padding-right: 1rem; - user-select: none; - opacity: 0.5; -} - -.highlight { - background: var(--color-neutrals-300); - - :global(.dark-mode) & { - background: var(--color-dark-100); - } -} - -.bottomBar { - box-sizing: border-box; - display: flex; - justify-content: space-between; - align-items: center; - background: var(--tertiary-background-color); - padding: 0 1rem; -} - -.fileName { - font-family: var(--code-font); - font-size: 0.75rem; - white-space: nowrap; - overflow: hidden; - padding-right: 0.5rem; -} - -.copyButton { - font-size: 0.75rem; - outline: none; -} - -.copyIcon { - margin-right: 0.5rem; -} diff --git a/src/components/InlineCodeSnippet.js b/src/components/InlineCodeSnippet.js deleted file mode 100644 index a4a9f52b6..000000000 --- a/src/components/InlineCodeSnippet.js +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import Highlight, { defaultProps } from 'prism-react-renderer'; -import lightTheme from 'prism-react-renderer/themes/github'; -import darkTheme from 'prism-react-renderer/themes/nightOwl'; -import styles from './InlineCodeSnippet.module.scss'; -import cx from 'classnames'; -import useDarkMode from 'use-dark-mode'; - -const InlineCodeSnippet = ({ children, language }) => { - const darkMode = useDarkMode(); - - return ( - - {({ style, className, tokens, getLineProps, getTokenProps }) => ( -
-          {tokens.map((line, i) => (
-            
- {line.map((token, key) => ( - - ))} -
- ))} -
- )} -
- ); -}; - -InlineCodeSnippet.propTypes = { - children: PropTypes.node.isRequired, - language: PropTypes.string, -}; - -export default InlineCodeSnippet; diff --git a/src/components/MDXCodeBlock.js b/src/components/MDXCodeBlock.js new file mode 100644 index 000000000..c06bc6bd2 --- /dev/null +++ b/src/components/MDXCodeBlock.js @@ -0,0 +1,36 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import CodeBlock from './CodeBlock'; + +const MDXCodeBlock = ({ + children, + className, + copy, + lineNumbers, + live, + lineHighlight, + ...props +}) => ( + + {children} + +); + +MDXCodeBlock.propTypes = { + children: PropTypes.string, + className: PropTypes.string, + copy: PropTypes.oneOf(['true', 'false']), + lineHighlight: PropTypes.string, + lineNumbers: PropTypes.oneOf(['true', 'false']), + live: PropTypes.oneOf(['true', 'false']), +}; + +export default MDXCodeBlock; diff --git a/src/components/MDXContainer.js b/src/components/MDXContainer.js index d4fdd835f..e081d4977 100644 --- a/src/components/MDXContainer.js +++ b/src/components/MDXContainer.js @@ -11,7 +11,7 @@ import Caution from './Caution'; import Important from './Important'; import Tip from './Tip'; import Intro from './Intro'; -import CodeSnippet from './CodeSnippet'; +import MDXCodeBlock from './MDXCodeBlock'; import styles from './MDXContainer.module.scss'; @@ -23,7 +23,8 @@ const components = { Important, Tip, Intro, - code: (props) => , + code: MDXCodeBlock, + pre: (props) => props.children, }; const MDXContainer = ({ className, children }) => { diff --git a/src/components/ReferenceExample.js b/src/components/ReferenceExample.js index 006606821..40d573f76 100644 --- a/src/components/ReferenceExample.js +++ b/src/components/ReferenceExample.js @@ -1,12 +1,8 @@ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import PropTypes from 'prop-types'; -import formatCode from '../utils/formatCode'; -import lightTheme from 'prism-react-renderer/themes/github'; -import darkTheme from 'prism-react-renderer/themes/nightOwl'; -import { LiveEditor, LiveError, LiveProvider } from 'react-live'; import styles from './ReferenceExample.module.scss'; import ReferencePreview from './ReferencePreview'; -import useDarkMode from 'use-dark-mode'; +import CodeBlock from './CodeBlock'; const platformStateContextMock = { timeRange: { @@ -20,8 +16,6 @@ const nerdletStateContextMock = { entityGuid: 'MTIzNDU2fEZPT3xCQVJ8OTg3NjU0MzIx', }; -const TRAILING_SEMI = /;\s*$/; - const ReferenceExample = ({ className, example, @@ -33,51 +27,43 @@ const ReferenceExample = ({ NerdletStateContext, } = window.__NR1_SDK__.default; const { live } = example.options; - let formattedCode; - const darkMode = useDarkMode(); - try { - formattedCode = formatCode(example.sourceCode).replace(TRAILING_SEMI, ''); - } catch (e) { - formattedCode = example.sourceCode; - } + const scope = useMemo( + () => ({ + ...window.__NR1_SDK__.default, + navigation: { + getOpenLauncherLocation: () => {}, + }, + }), + [] + ); + + const Preview = useCallback( + ({ className }) => ( + + ), + [useToastManager, previewStyle] + ); return (

{example.label}

- ({ - ...window.__NR1_SDK__.default, - navigation: { - // eslint-disable-next-line no-empty-function - getOpenLauncherLocation() {}, - }, - }), - [] - )} - code={formattedCode} - theme={darkMode.value ? darkTheme : lightTheme} - disabled={!live} + - {live && ( - - )} - - {live && } - + {example.sourceCode} +
diff --git a/src/components/styles.scss b/src/components/styles.scss index f2803e9d0..437a7547b 100644 --- a/src/components/styles.scss +++ b/src/components/styles.scss @@ -79,6 +79,24 @@ --color-teal-800: #003539; --color-teal-900: #002123; + // https://www.nordtheme.com/docs/colors-and-palettes + --color-nord-0: #2e3440; + --color-nord-1: #3b4252; + --color-nord-2: #434c5e; + --color-nord-3: #4c566a; + --color-nord-4: #d8dee9; + --color-nord-5: #e5e9f0; + --color-nord-6: #eceff4; + --color-nord-7: #8fbcbb; + --color-nord-8: #88c0d0; + --color-nord-9: #81a1c1; + --color-nord-10: #5e81ac; + --color-nord-11: #bf616a; + --color-nord-12: #d08770; + --color-nord-13: #ebcb8b; + --color-nord-14: #a3be8c; + --color-nord-15: #b48ead; + --primary-font-family: 'open sans', sans-serif; --secondary-font-family: 'effra', sans-serif; --tertiary-font-family: 'Ovo', serif; diff --git a/src/hooks/useFormattedCode.js b/src/hooks/useFormattedCode.js index a559e765e..7216f85b3 100644 --- a/src/hooks/useFormattedCode.js +++ b/src/hooks/useFormattedCode.js @@ -1,8 +1,8 @@ -import { useMemo } from 'react'; +import useShallowMemo from './useShallowMemo'; import formatCode from '../utils/formatCode'; const useFormattedCode = (code, options) => { - return useMemo(() => { + return useShallowMemo(() => { try { return formatCode(code, options); } catch (e) { diff --git a/src/hooks/useShallowMemo.js b/src/hooks/useShallowMemo.js new file mode 100644 index 000000000..b0adba701 --- /dev/null +++ b/src/hooks/useShallowMemo.js @@ -0,0 +1,19 @@ +import { useMemo, useRef } from 'react'; +import shallowEqual from '../utils/shallowEqual'; + +const useShallowMemo = (callback, deps) => { + const depsRef = useRef([]); + const previous = depsRef.current; + const equal = + deps.length === previous.length && + deps.every((dep, idx) => shallowEqual(dep, previous[idx])); + + if (!equal) { + depsRef.current = deps; + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(callback, depsRef.current); +}; + +export default useShallowMemo; diff --git a/src/templates/ApiReferenceTemplate.js b/src/templates/ApiReferenceTemplate.js index 242aeabbb..bb29016cb 100644 --- a/src/templates/ApiReferenceTemplate.js +++ b/src/templates/ApiReferenceTemplate.js @@ -2,7 +2,7 @@ import React from 'react'; import cx from 'classnames'; import { graphql } from 'gatsby'; import PropTypes from 'prop-types'; -import InlineCodeSnippet from '../components/InlineCodeSnippet'; +import CodeBlock from '../components/CodeBlock'; import Layout from '../components/Layout'; import PageTitle from '../components/PageTitle'; import Markdown from '../components/Markdown'; @@ -45,7 +45,7 @@ const ApiReferenceTemplate = ({ data }) => {

Usage

- {usage} + {usage}
{methods.length > 0 && ( diff --git a/src/templates/ComponentReferenceTemplate.js b/src/templates/ComponentReferenceTemplate.js index de72c227a..499b25f84 100644 --- a/src/templates/ComponentReferenceTemplate.js +++ b/src/templates/ComponentReferenceTemplate.js @@ -3,7 +3,7 @@ import cx from 'classnames'; import { graphql } from 'gatsby'; import PropTypes from 'prop-types'; -import InlineCodeSnippet from '../components/InlineCodeSnippet'; +import CodeBlock from '../components/CodeBlock'; import ReferenceExample from '../components/ReferenceExample'; import Layout from '../components/Layout'; import PageTitle from '../components/PageTitle'; @@ -63,7 +63,7 @@ const ComponentReferenceTemplate = ({ data }) => {

Usage

- {usage} + {usage}
{examples.length > 0 && ( diff --git a/src/utils/__tests__/array.js b/src/utils/__tests__/array.js new file mode 100644 index 000000000..04af7340f --- /dev/null +++ b/src/utils/__tests__/array.js @@ -0,0 +1,46 @@ +import { range, partition } from '../array'; + +describe('range', () => { + test('generates a range from start to end', () => { + expect(range(0, 5)).toEqual([0, 1, 2, 3, 4, 5]); + }); + + test('handles non-zero started range', () => { + expect(range(3, 6)).toEqual([3, 4, 5, 6]); + }); + + test('generates single number if start and end are the same', () => { + expect(range(4, 4)).toEqual([4]); + }); +}); + +describe('partition', () => { + test('generates a tuple of values that do and do not satisfy the predicate', () => { + const result = partition([1, 2, 3, 4, 5, 6], (num) => num % 2 === 0); + const [evens, odds] = result; + + expect(result).toHaveLength(2); + expect(evens).toEqual([2, 4, 6]); + expect(odds).toEqual([1, 3, 5]); + }); + + test('handles values that all satisfy the predicate', () => { + const [evens, odds] = partition([2, 4, 6], (num) => num % 2 === 0); + + expect(evens).toEqual([2, 4, 6]); + expect(odds).toEqual([]); + }); + + test('handles values that all do not satisfy the predicate', () => { + const [evens, odds] = partition([1, 3, 5], (num) => num % 2 === 0); + + expect(evens).toEqual([]); + expect(odds).toEqual([1, 3, 5]); + }); + + test('handles an empty array', () => { + const result = partition([], () => true); + + expect(result).toEqual([[], []]); + }); +}); diff --git a/src/utils/__tests__/shallowEqual.js b/src/utils/__tests__/shallowEqual.js new file mode 100644 index 000000000..92fb9145e --- /dev/null +++ b/src/utils/__tests__/shallowEqual.js @@ -0,0 +1,101 @@ +import shallowEqual from '../shallowEqual'; + +test('true when both objects are empty', () => { + const result = shallowEqual({}, {}); + + expect(result).toBe(true); +}); + +test('true when both objects are referentially equal', () => { + const obj = { a: 1, b: 2 }; + + const result = shallowEqual(obj, obj); + + expect(result).toBe(true); +}); + +test('true when all values are the same', () => { + const result = shallowEqual({ a: 1, b: 2 }, { a: 1, b: 2 }); + + expect(result).toBe(true); +}); + +test('true when values are referentially equal', () => { + const nestedObj = { a: 1 }; + + const result = shallowEqual({ key: nestedObj }, { key: nestedObj }); + + expect(result).toBe(true); +}); + +test('false when values are not shallow', () => { + const result = shallowEqual({ a: {} }, { a: {} }); + + expect(result).toBe(false); +}); + +test('false when one value has too many keys', () => { + const result = shallowEqual({ a: 1 }, { a: 1, b: 2 }); + + expect(result).toBe(false); +}); + +test('false when value is undefined but key is not present in second object', () => { + const result = shallowEqual({ a: undefined }, {}); + + expect(result).toBe(false); +}); + +test('true when all array items are equal', () => { + const result = shallowEqual([1, 2], [1, 2]); + + expect(result).toBe(true); +}); + +test('false when array length differs', () => { + const result = shallowEqual([1], [1, 2]); + + expect(result).toBe(false); +}); + +test('true when both values are null', () => { + const result = shallowEqual(null, null); + + expect(result).toBe(true); +}); + +test('true when both values are undefined', () => { + const result = shallowEqual(undefined, undefined); + + expect(result).toBe(true); +}); + +test('false when one of the values is null', () => { + const result = shallowEqual({}, null); + + expect(result).toBe(false); +}); + +test('false when one of the values is undefined', () => { + const result = shallowEqual({}, undefined); + + expect(result).toBe(false); +}); + +test('true when values are not objects and are equal', () => { + const result = shallowEqual(1, 1); + + expect(result).toBe(true); +}); + +test('false when values are not objects and are not equal', () => { + const result = shallowEqual(1, 2); + + expect(result).toBe(false); +}); + +test('false when one value is not an object', () => { + const result = shallowEqual({}, 2); + + expect(result).toBe(false); +}); diff --git a/src/utils/array.js b/src/utils/array.js new file mode 100644 index 000000000..44e3e61f3 --- /dev/null +++ b/src/utils/array.js @@ -0,0 +1,10 @@ +export const range = (a, b) => [...Array(b + 1).keys()].slice(a); + +export const partition = (arr, predicate) => + arr.reduce( + ([truthy, falsey], item) => + predicate(item) + ? [truthy.concat(item), falsey] + : [truthy, falsey.concat(item)], + [[], []] + ); diff --git a/src/utils/shallowEqual.js b/src/utils/shallowEqual.js new file mode 100644 index 000000000..cf76e428c --- /dev/null +++ b/src/utils/shallowEqual.js @@ -0,0 +1,22 @@ +const hasOwnProperty = Object.prototype.hasOwnProperty; + +const shallowEqual = (a, b) => { + if (a === b) { + return true; + } + + if (typeof a !== 'object' || !a || typeof b !== 'object' || !b) { + return false; + } + + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + + if (aKeys.length !== bKeys.length) { + return false; + } + + return aKeys.every((key) => hasOwnProperty.call(b, key) && a[key] === b[key]); +}; + +export default shallowEqual;