Laptop screen displaying a code editor in foreground with a blurred city scene in the background

Creating a Code System for Gatsby

Gatsby 2 warning

This was done some time ago and it was Gatsby 2 at the time. I haven't updated my blog yet so I'm not sure what if anything would be required.

My goal was to create a system for code examples that supports everything Lekoarts has done in a way that I can understand and control. I know I've added bits to this system on my blog but so much of it is what was in the original Code.js file. I must acknowledge how awesome that was!

I started with a new basic gatsby site using the hello-world starter, installed dependencies for MDX support, and code rendering.

npx gatsby new code-test https://github.com/gatsbyjs/gatsby-starter-hello-world
npm install gatsby-plugin-mdx @mdx-js/mdx @mdx-js/react
npm install mdx-utils
npm install prism-react-renderer react-live
npm install typeface-open-sans # a font for fun? Why am I doing this?!

Create the JavaScript Files That Will Run the System

/wrap-root-element.js
1import React from 'react'
2import { MDXProvider } from '@mdx-js/react'
3import { preToCodeBlock } from 'mdx-utils'
4import Code from './src/components/Code'
5
6// components is its own object outside of render so that the references to
7// components are stable
8const components = {
9 pre: (preProps) => {
10 const props = preToCodeBlock(preProps)
11 // if there's a codeString and some props, we passed the test
12 if (props) {
13 return <Code {...props} />
14 }
15 // it's possible to have a pre without a code in it
16 return <pre {...preProps} />
17 },
18 wrapper: ({ children }) => <>{children}</>,
19}
20export const wrapRootElement = ({ element }) => (
21 <MDXProvider components={components}>{element}</MDXProvider>
22)
  • Create a CSS file for the language tabs
/language-tabs.css
1.gatsby-highlight {
2 position: relative;
3 -webkit-overflow-scrolling: touch;
4}
5.gatsby-highlight pre[class*='language-'] {
6 -webkit-overflow-scrolling: touch;
7 padding: 1rem 0.75rem;
8 border-radius: 0.25rem;
9}
10.gatsby-highlight pre[class*='language-']::before {
11 background: black;
12 border-radius: 0 0 0.25rem 0.25rem;
13 color: white;
14 font-size: 12px;
15 letter-spacing: 0.025rem;
16 padding: 0.1rem 0.5rem;
17 position: absolute;
18 right: 1rem;
19 text-align: right;
20 text-transform: uppercase;
21 top: 0;
22}
23.gatsby-highlight pre[class~='language-javascript']::before {
24 content: 'js';
25 background: #f7df1e;
26 color: black;
27}
28.gatsby-highlight pre[class~='language-js']::before {
29 content: 'js';
30 background: #f7df1e;
31 color: black;
32}
33
34.gatsby-highlight pre[class~='language-html']::before {
35 content: 'html';
36 background: #005a9c;
37 color: white;
38}
39
40.gatsby-highlight pre[class~='language-css']::before {
41 content: 'css';
42 background: #ff9800;
43 color: white;
44}
  • Create a Code component (steal more like it!)
src/components/Code.js
1import React from 'react'
2import Highlight, { defaultProps } from 'prism-react-renderer'
3import theme from 'prism-react-renderer/themes/nightOwl'
4// import { LiveProvider, LiveEditor, LiveError, LivePreview } from 'react-live'
5
6const RE = /{([\d,-]+)}/
7
8const calculateLinesToHighlight = (meta) => {
9 if (!RE.test(meta)) {
10 return () => false
11 }
12 const lineNumbers = RE.exec(meta)[1]
13 .split(`,`)
14 .map((v) => v.split(`-`).map((x) => parseInt(x, 10)))
15 return (index) => {
16 const lineNumber = index + 1
17 const inRange = lineNumbers.some(([start, end]) =>
18 end ? lineNumber >= start && lineNumber <= end : lineNumber === start
19 )
20 return inRange
21 }
22}
23
24const Code = ({ codeString, language, metastring, ...props }) => {
25 const shouldHighlightLine = calculateLinesToHighlight(metastring)
26
27 return (
28 <Highlight
29 {...defaultProps}
30 code={codeString}
31 language={language}
32 theme={theme}
33 {...props}
34 >
35 {({ className, style, tokens, getLineProps, getTokenProps }) => (
36 <div className='gatsby-highlight' data-language={language}>
37 <pre className={className} style={style}>
38 {tokens.map((line, i) => {
39 const lineProps = getLineProps({ line, key: i })
40
41 if (shouldHighlightLine(i)) {
42 lineProps.className = `${lineProps.className} highlight-line`
43 }
44
45 return (
46 <div {...lineProps}>
47 <span className='line-number-style'>{i + 1}</span>
48 {line.map((token, key) => (
49 <span {...getTokenProps({ token, key })} />
50 ))}
51 </div>
52 )
53 })}
54 </pre>
55 </div>
56 )}
57 </Highlight>
58 )
59}
60
61export default Code
  • Apparently RE means RegEx. I got it but when I originally saw this variable again after months (year?) I thought, "What is RE?" I do see that what is assigned to it is RegEx but still!

  • The goal is to accept any or all of the following (I've indicated which of these are working after the initial code):

    ✅ language - a language that prism-react-render understands
    ✅ highlighted lines - specified in curly braces like {5-7,10} (highlight lines 5 thru 7 as well as 10)
    ✅ tab - tab on the code block showing the language in each languages chosen color (starting with javascript, js, html, and css)
    ⬜ title - specified after language (if there is one) with a : as title=my-title (if no language, still use :)
    ⬜ copy code button - something that makes it easy to copy the code block to the clipboard
    ⬜ react-live - if specified, will use the react-live renderer
    ⬜ line numbers - by default, code will render with line numbers unless noLineNumbers is specified
    ⬜ diff - Display like a language of diff so + lines are added color and - lines are deleted color, but with syntax highlighting. For language diff, prism renders all text as a single color for a + or - line.

Configure the Layout

  • Create Layout files
src/components/layout.js
1import React from 'react'
2import { MDXProvider } from '@mdx-js/react'
3import { preToCodeBlock } from 'mdx-utils'
4import Code from './Code'
5import './layout.css'
6
7const components = {
8 pre: (preProps) => {
9 const props = preToCodeBlock(preProps)
10 if (props) {
11 return <Code {...props} />
12 } else {
13 return <pre {...preProps} />
14 }
15 },
16}
17
18const Layout = ({ children }) => (
19 <MDXProvider components={components}>
20 <div style={{ margin: `0 auto`, maxWidth: 960, padding: `2rem` }}>{children}</div>
21 </MDXProvider>
22)
23
24export default Layout
src/components/layout.css
1html,
2body {
3 margin: 0;
4 padding: 0;
5}
6
7html {
8 font-family: sans-serif;
9 -ms-text-size-adjust: 100%;
10 -webkit-text-size-adjust: 100%;
11}
12
13.prism-code {
14 font-size: 1rem;
15 padding-top: 1rem;
16 padding-bottom: 1rem;
17 -webkit-overflow-scrolling: touch;
18 background-color: transparent;
19 overflow: auto;
20}
21
22.token {
23 display: inline-block;
24}
25
26p > code,
27li > code {
28 background: rgb(1, 22, 39);
29 color: rgb(214, 222, 235);
30 padding: 0.4em 0.3rem;
31}
32
33.gatsby-highlight {
34 font-size: 1rem;
35 position: relative;
36 -webkit-overflow-scrolling: touch;
37 overflow: auto;
38}
39
40gatsby-highlight > code[class*='language-'],
41.gatsby-highlight > pre[class*='language-'] {
42 word-spacing: normal;
43 word-break: normal;
44 overflow-wrap: normal;
45 line-height: 1.5;
46 tab-size: 4;
47 hyphens: none;
48}
49
50.line-number-style {
51 display: inline-block;
52 padding-left: 1em;
53 padding-right: 1em;
54 width: 1.2em;
55 user-select: none;
56 opacity: 0.3;
57 text-align: center;
58 position: relative;
59}
60
61.highlight-line {
62 background-color: rgb(2, 55, 81);
63 border-left: 4px solid rgb(2, 155, 206);
64}
65
66.highlight-line .line-number-style {
67 opacity: 0.5;
68 width: calc(1.2em - 4px);
69 left: -2px;
70}

I did add overflow: auto; to .prism-code in the CSS so the long lines wouldn't head off to the edge of the world.

Make it Work

  • Configure /gatsby-browser.js
  • Configure /gatsby-ssr.js
1import 'typeface-open-sans'
2import './language-tabs.css'
3
4import { wrapRootElement as wrap } from './wrap-root-element'
5
6export const wrapRootElement = wrap

Both files are the same!

Install the gatsby-source-filesystem plugin so I can have something to test

npm install gatsby-source-filesystem
/gatsby-config.js
1module.exports = {
2 plugins: [
3 {
4 resolve: 'gatsby-source-filesystem',
5 options: {
6 name: 'post',
7 path: `${__dirname}/blog`,
8 },
9 },
10 {
11 resolve: 'gatsby-plugin-mdx',
12 options: {
13 defaultLayouts: {
14 default: require.resolve('./src/components/layout.js'),
15 },
16 },
17 },
18 ],
19}

Test Data

Titles

I found some code to split the language into language and title (if they exist)

  • Add to Code.js
const getParams = (language = ``) => {
const [lang = ``, params = ``] = language.split(`:`)
return [lang.split(`language-`).pop().split(`{`).shift()].concat(
params.split(`&`).reduce((merged, param) => {
const [key, value] = param.split(`=`)
merged[key] = value
return merged
}, {})
)
}
const Code = ({ codeString, language, metastring, ...props }) => {
//...
const [lang, { title = `` }] = getParams(language)
//...
return (
<>
{title && <TitleContainer>{title}</TitleContainer>}
...
</>
)
TitleContainer Definition
1export const TitleContainer = styled.div`
2 background-color: var(--code-title-background);
3 color: var(--code-title-text);
4 padding: 0.6rem 1rem;
5 border-radius: 0.4rem 0.4rem 0 0;
6 font-size: 0.9rem;
7 margin-top: 0.8rem;
8`

TitleContainer turns out to be a styled-component

✅ title - specified after language (if there is one) with a : as title=my-title (if no language, still use :)

To include spaces, use a non-breaking space &nbsp; like the good 'ole days!

Copy Code Button

src/utils/copy-to-clipboard.js
1// https://github.com/gatsbyjs/gatsby/blob/master/www/src/utils/copy-to-clipboard.js
2
3const copyToClipboard = (str) => {
4 const clipboard = window.navigator.clipboard
5 /*
6 * fallback to older browsers (including Safari)
7 * if clipboard API not supported
8 */
9 if (!clipboard || typeof clipboard.writeText !== `function`) {
10 const textarea = document.createElement(`textarea`)
11 textarea.value = str
12 textarea.setAttribute(`readonly`, true)
13 textarea.setAttribute(`contenteditable`, true)
14 textarea.style.position = `absolute`
15 textarea.style.left = `-9999px`
16 document.body.appendChild(textarea)
17 textarea.select()
18 const range = document.createRange()
19 const sel = window.getSelection()
20 sel.removeAllRanges()
21 sel.addRange(range)
22 textarea.setSelectionRange(0, textarea.value.length)
23 document.execCommand(`copy`)
24 document.body.removeChild(textarea)
25
26 return Promise.resolve(true)
27 }
28
29 return clipboard.writeText(str)
30}
31
32export default copyToClipboard
  • Used a styled-component for this
CopyCode Definition
1export const CopyCode = styled.button`
2 background-color: var(--code-title-background);
3 color: var(--code-title-text);
4 position: absolute;
5 right: 0;
6 top: 0;
7 border: 0;
8 border-radius: 3px;
9 margin: 0.5em;
10 opacity: 0.5;
11 padding: 0.2rem;
12 &:hover {
13 opacity: 1;
14 }
15`

Code.js is newer!

All the following is slightly different code because it is from a different codebase. I didn't get all the features working in my test project, but in my blog, they workmostly.

Updated Code.js
// Add import
import copyToClipboard from '../utils/copy-to-clipboard.js'
...
export const Code = ({ codeString, noLineNumbers = false, language, metastring, ...props }) => {
//...
const handleClick = () => {
copyToClipboard(codeString)
}
//...
return (
//...
<pre className={className} style={style}>
<CopyCode onClick={handleClick}>Copy</CopyCode>
//...
</pre>
)
}

✅ copy code button - something that makes it easy to copy the code block to the clipboard

Leave Out the Line Numbers

Sometimes, we don't need the line numbers. I mean, really, if it's a simple bash example of an npm or yarn install, do you need it to say, "This is line 1?"

Updated Code.js
export const Code = ({ codeString, noLineNumbers = false, language, metastring, ...props }) => {
//...
const hasLineNumbers = !noLineNumbers && language !== `noLineNumbers`
// Later in the return()
//...
return (
//...
return (
<div {...lineProps}>
{hasLineNumbers && <span className='line-number-style'>{i + 1}</span>}
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token, key })} />
))}
</div>
)
//...
)

✅ line numbers - by default, code will render with line numbers unless noLineNumbers is specified

Implementing React-live

Updated Code.js
// Add react-live import
import { LiveEditor, LiveError, LivePreview, LiveProvider } from 'react-live'
// If react-live is specfied, then use it
export const Code = ({ codeString, noLineNumbers = false, language, metastring, ...props }) => {
//...
if (props['react-live']) {
return (
<>
{title && <TitleContainer>{title}</TitleContainer>}
<LiveProvider code={codeString} noInline={true} theme={theme === 'dark' ? nightOwl : nightOwlLight}>
<LiveEditor />
<LiveError />
<LivePreview />
</LiveProvider>
</>
)
}
//...

✅ react-live - if specified, will use the react-live renderer

Final version of Code.js

It doesn't have all the code, but it's currently what's in GitHub for this site so you can go and look at anything that is imported and not described.

1import * as React from 'react'
2import { useContext } from 'react'
3import Highlight, { defaultProps } from 'prism-react-renderer'
4import nightOwl from 'prism-react-renderer/themes/nightOwl'
5import nightOwlLight from 'prism-react-renderer/themes/nightOwlLight'
6import { TitleContainer, CopyCode, ThemeContext } from '../components'
7import copyToClipboard from '../utils/copy-to-clipboard.js'
8import { LiveEditor, LiveError, LivePreview, LiveProvider } from 'react-live'
9
10const calculateLinesToHighlight = (meta) => {
11 const LINE_NUMBERS_TEST = /{([\d,-]+)}/
12 if (!LINE_NUMBERS_TEST.test(meta)) {
13 return () => false
14 }
15 const lineNumbers = LINE_NUMBERS_TEST.exec(meta)[1]
16 .split(`,`)
17 .map((v) => v.split(`-`).map((x) => parseInt(x, 10)))
18 return (index) => {
19 const lineNumber = index + 1
20 const inRange = lineNumbers.some(([start, end]) =>
21 end ? lineNumber >= start && lineNumber <= end : lineNumber === start
22 )
23 return inRange
24 }
25}
26
27const getParams = (language = ``) => {
28 const [lang = ``, params = ``] = language.split(`:`)
29 return [lang.split(`language-`).pop().split(`{`).shift()].concat(
30 params.split(`&`).reduce((merged, param) => {
31 const [key, value] = param.split(`=`)
32 merged[key] = value
33 return merged
34 }, {})
35 )
36}
37
38export const Code = ({
39 codeString,
40 noLineNumbers = false,
41 language,
42 metastring,
43 ...props
44}) => {
45 const { theme } = useContext(ThemeContext)
46 const shouldHighlightLine = calculateLinesToHighlight(metastring)
47 const hasLineNumbers = !noLineNumbers && language !== `noLineNumbers`
48 const [lang, { title = `` }] = getParams(language)
49 const hasDiff = lang.slice(0, 5) === 'diff-'
50 const handleClick = () => {
51 copyToClipboard(codeString)
52 }
53 if (props['react-live']) {
54 return (
55 <>
56 {title && <TitleContainer>{title}</TitleContainer>}
57 <LiveProvider
58 code={codeString}
59 noInline={true}
60 theme={theme === 'dark' ? nightOwl : nightOwlLight}
61 >
62 <LiveEditor />
63 <LiveError />
64 <LivePreview />
65 </LiveProvider>
66 </>
67 )
68 }
69
70 const langType = hasDiff ? lang.slice(5) : lang
71
72 return (
73 <>
74 {title && <TitleContainer>{title}</TitleContainer>}
75 <Highlight
76 {...defaultProps}
77 code={codeString}
78 language={langType}
79 theme={theme === 'dark' ? nightOwl : nightOwlLight}
80 {...props}
81 >
82 {({ className, style, tokens, getLineProps, getTokenProps }) => (
83 <div className='gatsby-highlight' data-language={langType}>
84 <pre className={className} style={style}>
85 <CopyCode onClick={handleClick}>Copy</CopyCode>
86 {tokens.map((line, i) => {
87 const lineProps = getLineProps({ line, key: i })
88 if (shouldHighlightLine(i)) {
89 lineProps.className = `${lineProps.className} highlight-line`
90 }
91
92 if (
93 hasDiff &&
94 !shouldHighlightLine.length &&
95 (line[1].content === '+' || line[1].content === '-')
96 ) {
97 lineProps.className = `${lineProps.className} ${
98 line[1].content === '+' ? 'diff-line-add' : 'diff-line-sub'
99 }`
100 }
101
102 return (
103 <div {...lineProps}>
104 {hasLineNumbers && <span className='line-number-style'>{i + 1}</span>}
105 {line.map((token, key) => (
106 <span key={key} {...getTokenProps({ token, key })} />
107 ))}
108 </div>
109 )
110 })}
111 </pre>
112 </div>
113 )}
114 </Highlight>
115 </>
116 )
117}

No Diff with Syntax yet

My dream of having diff with syntax highlighting has not come true yet. I've seem to have gotten close on occasion, but as of today, 2022-02-07, I haven't done it. I haven't really tried since way back when but still. I've not received any magical incite.

❌ diff - Display like a language of diff so + lines are added color and - lines are deleted color, but with syntax highlighting. For language diff, prism renders all text as a single color for a + or - line.

References
Attributions

Photo by Sean Lim (@seanlimm) on Unsplash

Built with Gatsby from 2020 thru 2024 (and beyond?!)