Syntax Highlighting
Syntax Highlighting! Good luck doing this in Trix!
As with everything in Rhino there are 2 parts:
- Add to our frontend editor
- Make sure we permit tags, attributes, styles, etc in ActionText.
Luckily for us, we don’t need to do the second part! The syntax highlighter we’ll be using from TipTap only uses <span>
, <pre>
, and <code>
which are already permitted by default in ActionText.
TipTap provides an official extension using Lowlight
https://tiptap.dev/api/nodes/code-block-lowlight
Installation
Assuming you have Rhino installed and working, let’s start by installing the additional dependencies we need.
yarn add lowlight @tiptap/extension-code-block-lowlight hast-util-to-html he
Adding to RhinoEditor
The first step is to add JavaScript to enhance our editor. Also of note we need to disable
the built-in codeBlock
extension.
// app/javascript/application.js
import "rhino-editor/exports/styles/trix.css"
// This loads all languages
import {common, createLowlight} from 'lowlight'
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
// This will important for storing fully syntax highlighted code.
import he from 'he'
import {toHtml} from 'hast-util-to-html'
const lowlight = createLowlight(common)
const syntaxHighlight = CodeBlockLowlight.configure({
lowlight,
})
// load specific languages only
// import { lowlight } from 'lowlight/lib/core'
// import javascript from 'highlight.js/lib/languages/javascript'
// lowlight.register({javascript})
function extendRhinoEditor (event) {
const rhinoEditor = event.target
if (rhinoEditor == null) return
const form = rhinoEditor.closest('form')
if (form) {
preProcessRhinoFormInputs(form)
}
rhinoEditor.starterKitOptions = {
...rhinoEditor.starterKitOptions,
// We disable codeBlock from the starterkit to be able to use CodeBlockLowlight's extension.
codeBlock: false
}
rhinoEditor.extensions = [syntaxHighlight]
rhinoEditor.rebuildEditor?.()
}
document.addEventListener("rhino-before-initialize", extendRhinoEditor, { once: true })
function preProcessRhinoFormInputs(form) {
const rhinoInputs = [...form.elements].filter((el) => el.classList.contains("rhino-editor-input"))
rhinoInputs.forEach((inputElement) => {
inputElement.value = stripHljsSpans(inputElement.value)
})
}
// When editing existing code blocks, strip the highlighting spans that wrap the code
// blocks. This allows CodeBlockLowlight to then properly re-parse the code blocks
// for highlighting. Without this, the spans do appear in the editor's highlighted
// code blocks, but some newlines are missing. The missing newlines will be
// when they are at the end of a line with only whitespace after them. Those
// newlines get lost in processing if these spans are not first stripped.
function stripHljsSpans(content) {
const tempDoc = new DOMParser().parseFromString(content, 'text/html')
tempDoc.querySelectorAll('pre > code').forEach(codeEl => {
const spans = codeEl.querySelectorAll('span[class^="hljs-"]')
spans.forEach(span => {
const textNode = document.createTextNode(span.textContent)
span.parentNode.replaceChild(textNode, span)
})
})
return tempDoc.body.innerHTML
}
// This next part is specifically for storing fully syntax highlighted markup in your database.
//
// On form submission, it will rewrite the value of the hidden input field with the appropriate
// code-highlighting spans from lowlight.
const highlightCodeblocks = (content) => {
const doc = new DOMParser().parseFromString(content, 'text/html');
doc.querySelectorAll('pre > code').forEach((el) => {
const languageClass = [...el.classList].find(cls => cls.startsWith('language-'));
const language = languageClass ? languageClass.replace('language-', '') : null;
// Decode the content before re-parsing so that we do not double-encode any HTML entities.
const decodedContent = he.decode(el.innerHTML)
const html = language ?
toHtml(lowlight.highlight(language, decodedContent).children) :
toHtml(lowlight.highlightAuto(decodedContent).children);
el.innerHTML = html;
});
return doc.body.innerHTML;
};
document.addEventListener("submit", (e) => {
// find all rhino-editor inputs attached to this form and transform them.
const rhinoInputs = [...e.target.elements].filter((el) => el.classList.contains("rhino-editor-input"))
rhinoInputs.forEach((inputElement) => {
inputElement.value = highlightCodeblocks(inputElement.value)
})
})
The next step is to choose a theme. I went with the OneDark
theme, but feel free to choose any theme you wish.
/*
OneDark theme from here: https://github.com/highlightjs/highlight.js/blob/main/src/styles/atom-one-dark.css
Atom One Dark by Daniel Gamage
Original One Dark Syntax theme from https://github.com/atom/one-dark-syntax
base: #282c34
mono-1: #abb2bf
mono-2: #818896
mono-3: #5c6370
hue-1: #56b6c2
hue-2: #61aeee
hue-3: #c678dd
hue-4: #98c379
hue-5: #e06c75
hue-5-2: #be5046
hue-6: #d19a66
hue-6-2: #e6c07b
*/
.trix-content .hljs {
color: #abb2bf;
background: #0d0d0d;
}
.trix-content pre {
color: #abb2bf;
background: #0d0d0d;
border-radius: 0.5rem;
font-family: "JetBrainsMono", monospace;
padding: 0.75rem 1rem;
}
.trix-content code {
background: none;
color: #abb2bf;
background: #0d0d0d;
color: inherit;
font-size: 0.95em;
padding: 0;
}
.trix-content .hljs-comment,
.trix-content .hljs-quote {
color: #5c6370;
font-style: italic;
}
.trix-content .hljs-doctag,
.trix-content .hljs-keyword,
.trix-content .hljs-formula {
color: #c678dd;
}
.trix-content .hljs-section,
.trix-content .hljs-name,
.trix-content .hljs-selector-tag,
.trix-content .hljs-deletion,
.trix-content .hljs-subst {
color: #e06c75;
}
.trix-content .hljs-literal {
color: #56b6c2;
}
.trix-content .hljs-string,
.trix-content .hljs-regexp,
.trix-content .hljs-addition,
.trix-content .hljs-attribute,
.trix-content .hljs-meta .trix-content .hljs-string {
color: #98c379;
}
.trix-content .hljs-attr,
.trix-content .hljs-variable,
.trix-content .hljs-template-variable,
.trix-content .hljs-type,
.trix-content .hljs-selector-class,
.trix-content .hljs-selector-attr,
.trix-content .hljs-selector-pseudo,
.trix-content .hljs-number {
color: #d19a66;
}
.trix-content .hljs-symbol,
.trix-content .hljs-bullet,
.trix-content .hljs-link,
.trix-content .hljs-meta,
.trix-content .hljs-selector-id,
.trix-content .hljs-title {
color: #61aeee;
}
.trix-content .hljs-built_in,
.trix-content .hljs-title.class_,
.trix-content .hljs-class .trix-content .hljs-title {
color: #e6c07b;
}
.trix-content .hljs-emphasis {
font-style: italic;
}
.trix-content .hljs-strong {
font-weight: bold;
}
.trix-content .hljs-link {
text-decoration: underline;
}
<input type="hidden" class="rhino-editor-input" id="syntax-highlight-input" value="<pre><code class='highlight-js'>console.log('Hello World')</code></pre>">
<rhino-editor id="syntax-highlight-editor" input="syntax-highlight-input"></rhino-editor>
For additional themes, you can checkout the HighlightJS
https://github.com/highlightjs/highlight.js/tree/main/src/styles