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-worldnpm install gatsby-plugin-mdx @mdx-js/mdx @mdx-js/reactnpm install mdx-utilsnpm install prism-react-renderer react-livenpm install typeface-open-sans # a font for fun? Why am I doing this?!
Create the JavaScript Files That Will Run the System
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 to7// components are stable8const components = {9 pre: (preProps) => {10 const props = preToCodeBlock(preProps)11 // if there's a codeString and some props, we passed the test12 if (props) {13 return <Code {...props} />14 }15 // it's possible to have a pre without a code in it16 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
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!)
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 () => false11 }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 + 117 const inRange = lineNumbers.some(([start, end]) =>18 end ? lineNumber >= start && lineNumber <= end : lineNumber === start19 )20 return inRange21 }22}23
24const Code = ({ codeString, language, metastring, ...props }) => {25 const shouldHighlightLine = calculateLinesToHighlight(metastring)26
27 return (28 <Highlight29 {...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:
astitle=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 unlessnoLineNumbers
is specified
⬜ diff - Display like a language ofdiff
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
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
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
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
- Create blog post
blog/code-test.mdx
to try this all out. - Put anything you want in there but I did copy the original tests from Language Tabs for Markdown & MDX Code Blocks | Lennart Jörgens (lekoarts.de).
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>} ... </> )
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
like the good 'ole days!
Copy Code Button
- Add
utils/copy-to-clipboard.js
- I got it from a Gatsby GitHub location that no longer exists, but I'm sure it's findable. Oh, here it is…
1// https://github.com/gatsbyjs/gatsby/blob/master/www/src/utils/copy-to-clipboard.js2
3const copyToClipboard = (str) => {4 const clipboard = window.navigator.clipboard5 /*6 * fallback to older browsers (including Safari)7 * if clipboard API not supported8 */9 if (!clipboard || typeof clipboard.writeText !== `function`) {10 const textarea = document.createElement(`textarea`)11 textarea.value = str12 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
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 work—mostly.
// Add importimport 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?"
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
// Add react-live importimport { LiveEditor, LiveError, LivePreview, LiveProvider } from 'react-live'
// If react-live is specfied, then use itexport 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 () => false14 }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 + 120 const inRange = lineNumbers.some(([start, end]) =>21 end ? lineNumber >= start && lineNumber <= end : lineNumber === start22 )23 return inRange24 }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] = value33 return merged34 }, {})35 )36}37
38export const Code = ({39 codeString,40 noLineNumbers = false,41 language,42 metastring,43 ...props44}) => {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 <LiveProvider58 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) : lang71
72 return (73 <>74 {title && <TitleContainer>{title}</TitleContainer>}75 <Highlight76 {...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
- Free & Open Source Gatsby Themes by LekoArts and perusing the GitHub repo
- Language Tabs for Markdown & MDX Code Blocks | Lennart Jörgens (lekoarts.de)
- Adding Line Numbers and Code Highlighting to MDX | Lennart Jörgens (lekoarts.de)
Attributions
Photo by Sean Lim (@seanlimm) on Unsplash