Jses.ioby Shaojiang

Next.js 13: format code snippets from Contentful

Shaojiang

Date:

Next.js 13: format code snippets from Contentful

Contentful is a convenient way to host article content. For technical articles, it's very common to have code snippets, either inlined or in blocks. Most likely, we want to highlight the code snippets with some syntax highlighter. This article introduces how we can interpret the code snippets from Contentful and highlight them in a Next.js app.

1. Preparation

Let's start from a Next.js example repo with Contentful integration there: cms-contentful. We would like to have a codebase that is able to fetch and render content from Contentful. Simply follow this instruction: Next.js Starter with Contentful. Please do remember to add content mannually to Contentful:

Add at least 1 author entry and 2 post entries into your space. Click on Content in the top navigation bar to do so. Make sure you publish each entry.

Tip: after publishing content on Contentful, need to clear the local cache to refetch the content to get latest update: rm -rf .next && yarn dev.

After going through the steps, you should now be able to view pages locally http://localhost:3000/posts/whats-jses-io:

On top of this, let's handle the code snippets styling.

2. Rich Text Rendering

Usually, we put code snippets here and there randomly within an article. Such an article is usually stored in the field type of Rich Text. This field is similar to traditional "What you see is what you get" (wysiwyg) editors. The key difference here is that the Contentful Rich Text Field (RTF) response is returned as pure JSON rather than HTML.

In an React app, Contentful provides an official renderer library rich-text-react-renderer to help developers render the Rich Text content. Contentful provides the Rich Text content in the format of JSON, representing a tree structure with various BLOCKS and MARKS, which are defined in repo rich-text-types. The JSON is like:

1{
2 "nodeType": "document",
3 "data": {},
4 "content": [
5 {
6 "nodeType": "paragraph",
7 "data": {},
8 "content": [
9 {
10 "nodeType": "text",
11 "value": "It's a community for ",
12 "marks": [],
13 "data": {}
14 },
15 {
16 "nodeType": "text",
17 "value": "JavaScript",
18 "marks": [{ "type": "code" }],
19 "data": {}
20 },
21 {
22 "nodeType": "text",
23 "value": " Coder to play with their ",
24 "marks": [],
25 "data": {}
26 },
27 {
28 "nodeType": "text",
29 "value": "new ideas",
30 "marks": [{ "type": "bold" }],
31 "data": {}
32 },
33 {
34 "nodeType": "text",
35 "value": " and apply the new techs.",
36 "marks": [],
37 "data": {}
38 }
39 ]
40 }
41 ]
42}

Then, we call function documentToReactComponents from rich-text-react-renderer to transform the JSON into React components. By default, a code snippet is a MARK, which renders to be inlined like "<code>[snippet code]</code>". With the prose classname by Tailwind, it renders like:

3. Style Customization

If we would like to customize the rendered components, documentToReactComponents provides options for that purpose. Depending on the node type, custom renderers could be provided like:

1export function Markdown({ content }: { content: Content }) {
2 return documentToReactComponents(content.json, {
3 renderNode: {
4 [BLOCKS.EMBEDDED_ASSET]: (node: any) => (
5 ...
6 ),
7 },
8 renderMark: {
9 [MARKS.CODE]: (text) => <code className='jses-inline-code'>{text}</code>,
10 },
11 })
12}

Then define the class jses-inline-code like:

1.jses-inline-code:before,
2.jses-inline-code:after {
3 content: '';
4}
5.jses-inline-code {
6 @apply text-sm font-normal p-1 rounded bg-purple-200 font-mono;
7}
8

Then the inline code will be rendered to be:

4. Multi-lines Code Styles

In technical articles, we usually have large piece of multi-lines snippets, like:

With the styles above, it will be rendered like:

For multi-lines code snippets, developers may prefer to use syntax highlighter like react-syntax-highlighter. It supports colored rendering of different variables for different languages, showing line numbers and more other features.

However, Contentful does not distinguish the inline code and multi-lines code snippets. They are both in MARK node type, while the value of multi-lines code is like:

".jses-inline-code {\n @apply text-sm font-normal p-1 rounded bg-purple-200 font-mono;\n}"

There are line breaks `\n` in the string. One way to resolve this quickly is to detect line breaks in the string. If there is any line break, render it with react-syntax-highlighter, otherwise render it with simple <code> block:

1
2import SyntaxHighlighter from 'react-syntax-highlighter'
3import { shadesOfPurple } from 'react-syntax-highlighter/dist/esm/styles/hljs'
4... ...
5 renderMark: {
6 [MARKS.CODE]: text => text.indexOf('\n') < 0
7 ? <code className='jses-inline-code'>{text}</code>
8 : (
9 <SyntaxHighlighter
10 language="javascript"
11 style={shadesOfPurple}
12 showLineNumbers
13 wrapLines
14 wrapLongLines
15 >
16 {text}
17 </SyntaxHighlighter>
18 ),
19 },

This solution does not quite work well because:

  1. Occasionally we would like to render single line of code with react-syntax-highlighter, this approach cannot achieve

  2. We are not able to render the code snippets of different languages.

So up to now, 27 Oct 2023, the only way is like the solution in Contentful Rich Text Rendering: define a custom content modal for snippets and give it a custom renderer.

The basic idea is: instead of putting code snippets directly inside the Rich Text field, embed an Entry, so that front-end is able to distinguish and style it. Here let's do it step by step.

4.1 New model "Snippet"

On Contentful dashboard, create a new model called "Snippet".

Create a Snippet entry with the code you would like to show, and embed it to the article:

4.2 Fetch embedded entries

The embedded entries are not returned by the current GraphQL query. We need to modify the query to cross refer to the entries and fetch the content. The query in file lib/api.ts is like:

1const POST_GRAPHQL_FIELDS = `
2 slug
3 ... ...
4 content {
5 json
6 links {
7 assets {
8 ... ...
9 }
10 entries {
11 block {
12 sys {
13 id
14 }
15 __typename
16 ... on Snippet {
17 title
18 language
19 snippet
20 }
21 }
22 }
23 }
24 }
25`

Basically, it fetches the refered entries to store in the response JSON at path content.links.entries. And __typename is the node type of the entry, which is snippet for us here. Here we need it to allow probably more other types of entries in future.

Check the full file content in the demo repo.

4.3 Embedded entries styling

We would like to highlight the code snippets with react-syntax-highlighter, so install it first:

1yarn install react-syntax-highlighter
2yarn install --dev @types/react-syntax-highlighter

We here use TypeScript by default, so will also need to install the typing library @types/react-syntax-highlighter. Modify the renderer component lib/markdown.tsx to render the embedded entries:

1export function Markdown({ content }: { content: Content }) {
2 const entries = content?.links?.entries?.block;
3 return documentToReactComponents(content.json, {
4 renderNode: {
5 [BLOCKS.EMBEDDED_ASSET]: (node: any) => (
6 ...
7 ),
8
9 [BLOCKS.EMBEDDED_ENTRY]: (node, t) => {
10 const id = node?.data?.target?.sys?.id || '';
11 const entry = entries?.find((entry: any) => entry?.sys?.id === id);
12 const contentType = entry?.__typename || '';
13
14 if (!entry) {
15 return null;
16 }
17
18 switch (contentType.toLowerCase()) {
19 // Contentful Snippet entity.
20 case 'snippet': {
21 let { language, snippet } = entry;
22 return (
23 <SyntaxHighlighter
24 language={language.toLowerCase()}
25 style={shadesOfPurple}
26 showLineNumbers={snippet.indexOf('\n') > -1}
27 wrapLines
28 wrapLongLines
29 >
30 {snippet}
31 </SyntaxHighlighter>
32 );
33 }
34 }
35 },
36 },
37 renderMark: {
38 [MARKS.CODE]: (text) => <code className='jses-inline-code'>{text}</code>,
39 },
40 })
41}

In the code, we define a custom renderer for BLOCKS.EMBEDDED_ENTRY. If the __typename is snippet, we render the code snippet with the syntax highlighter.

Note: we also add TypeScript types in the codebase. If you are not using TypeScript, simply ignore them. For all type definitions, check the demo repo.

Finally, we achieve what we are expecting:

5. Performance Tuning

As a performance sensitive developer, you will quickly notice that react-syntax-highlighter introduces a gzipped size 269kb to the bundle. It's basically the largest library in my application. To reduce the bundle size, use Light Build instead. The code will be like:

1import { Light as SyntaxHighlighter, SyntaxHighlighterProps } from 'react-syntax-highlighter';
2import bash from 'react-syntax-highlighter/dist/esm/languages/hljs/bash';
3import css from 'react-syntax-highlighter/dist/esm/languages/hljs/css';
4import java from 'react-syntax-highlighter/dist/esm/languages/hljs/java';
5import javascript from 'react-syntax-highlighter/dist/esm/languages/hljs/javascript';
6import shadesOfPurple from 'react-syntax-highlighter/dist/esm/styles/hljs/shades-of-purple';
7
8SyntaxHighlighter.registerLanguage('bash', bash);
9SyntaxHighlighter.registerLanguage('css', css);
10SyntaxHighlighter.registerLanguage('java', java);
11SyntaxHighlighter.registerLanguage('javascript', javascript);

Try to check the bundle size (I use @next/bundle-analyzer), it's reduce by ~250kb !

6. Conclusion

In this article, we style the code snippets from Contentful for 2 cases:

  1. For inline code, render MARKS.CODE to <code>...<code>

  2. For multi-lines snippet, create a separate Content Model "Snippet" and render BLOCKS.EMBEDDED_ENTRY

Please check the demo repo for the working code. Cheers.