Server Side Rendering for read-only codeblocks in CM6?

I’m working on a static site that is server-side generated.

I see completely why CM6 normally should be client-side rendered only.

However for read-only blocks I’d love to use CM6 with SSR for the readonly blog-code-snippets use case.

I know I could run it through JSDom or similar tool to get the markup as a string, though I’d prefer to minimize external tooling like that if possible.

I think without too much trouble I could fork CM6 and write a “SSREditorView()” fn that simply returns the markup string.

@marijn Is there any appetite at all for this in the main repo? I’m happy to issue a PR once I have something working if there’s shared interest.

Thank you for CM6. It’s amazing.

No, this is definitely out of scope for the core library. The highlighting should be possible without a DOM via highlightTree, and for uneditable content adding a line number gutter should also be pretty easy using a CSS grid.

2 Likes

Understood. Thanks! I’ll give those a shot.

@marijn Thanks for all your help. I gave this a try.

I looked at the tests on the highlight module and was able to figure out how to generate a tree, then run highlightTree() over that tree.

It appears to run correctly. However, for the life of me, I cannot figure out how to turn the tree into a domString, so I can render it on the server.

Any ideas?

// I run this script in node and it runs fine
var highlight = require("@codemirror/highlight");
var language = require("@codemirror/language");
var langJS = require("@codemirror/lang-javascript");
var stateLib = require("@codemirror/state");
var viewLib = require("@codemirror/view");
var javascriptLanguage = langJS.javascriptLanguage;

// generate state and a tree
var state = stateLib.EditorState.create({doc: "alert();", extensions: [javascriptLanguage.extension]})
var tree = language.syntaxTree(state);
var tree2 = language.ensureSyntaxTree(state, state.doc.length, 1e9);


highlight.highlightTree(tree, function(tag, scope){
  console.log('#tag', tag);
  console.log('#scope', scope);
  return "TEST-CLASSNAME"
}, (from, to, token) => {
  console.log('from', from);
  console.log('to', to);
  console.log('token', token);
});

// Q: How can I turn tree into a domString now?




// requires document. So this won't work.
// let view = new viewLib.EditorView({
//     state: state.EditorState.create({
//       doc: "alert();",  
//       // extensions,
//       readOnly: true, 
//       MapMode: "trackDel",
//       theme:
//       {
//         "&": { height: "300px" },
//         ".cm-scroller": { overflow: "auto" }
//       }
//        // will this override or just add?
//     })
//   })

Build up a string containing <span> tags with the appropriate classes, wrapping the parts of the document covered by those tokens?

Ah I see lol. I was thinking I was missing some sort of API method.

You’re saying I need to manually build up the string from the metadata now attached to the tree. Thanks!

As it so happens I was working on this exact thing today.
With some pointers from Static highlighting using CM v6 - /next - discuss.CodeMirror, I implemented the following. Hope it helps as a starting point

import {Language} from "@codemirror/language";
import {Decoration} from "@codemirror/view";
import {RangeSetBuilder} from "@codemirror/rangeset";
import {defaultHighlightStyle, highlightTree} from "@codemirror/highlight";
import {Text} from "@codemirror/text";

export function getHighlights(textContent: string, language: Language): string {
    const tree = language.parser.parse(textContent);
    let markCache: { [cls: string]: Decoration } = Object.create(null)

    let builder = new RangeSetBuilder<Decoration>()
    highlightTree(tree, defaultHighlightStyle.match, (from, to, style) => {
        builder.add(from, to, markCache[style] || (markCache[style] = Decoration.mark({class: style})))
    });
    let decorationRangeSet = builder.finish();

    let html = '';
    let text = Text.of(textContent.split("\n"));
    for (let i = 1; i <= text.lines; i++) {
        let line = text.line(i), pos = line.from, cursor = decorationRangeSet.iter(line.from), lineInnerHtml = '';

        while (cursor.value && cursor.from < line.to) {
            if (cursor.from > pos) {
                lineInnerHtml += `${text.sliceString(pos, cursor.from)}`;
            }
            lineInnerHtml += `<span class="${cursor.value.spec.class}">${text.sliceString(cursor.from, Math.min(line.to, cursor.to))}</span>`
            pos = cursor.to;
            cursor.next();
        }
        if (pos < line.to) {
            lineInnerHtml += `${text.sliceString(pos, line.to)}`;
        }
        html += `<div class="cm-line">${lineInnerHtml || '<br/>'}</div>`;
    }
    return `<pre>${html}</pre>`;
}
1 Like

@nmanandhar YES. Amazing. Thank you. I’ll give this a shot and report when I have something working.

@marijn Thanks to you and Nirmal above I have it pretty much working. I’m about to publish an npm module to make this easier.

Only 1 thing I’m a little stuck on (but still have mostly working): CSS rendering.

Here’s what I’m currently doing:

// more code above that uses highlightTree() to generate the proper markup
// FIXME: don't hard-code 'ͼ1 ͼ2 ͼ4'
return `
<div class="cm-editor ͼ1 ͼ2 ͼ4">
  <div class="cm-scroller">
    <div class="cm-content">${tokenizedStringWithClassNames}</div>
  </div>
</div>`;

Which generates this:

<div class="cm-editor ͼ1 ͼ2 ͼ4">
  <div class="cm-scroller">
    <div class="cm-content">
      <div class="cm-line"><span class="ͼb">function</span> <span class="ͼg">add</span>(<span class="ͼg">a</span>,<span class="ͼg">b</span>){</div>
      <div class="cm-line">  <span class="ͼb">return</span> a+b;</div>
      <div class="cm-line">} </div>
      <div class="cm-line"><span class="ͼm">// amazing comment!</span></div>
    </div>
  </div>
</div>

So far so good.

import {oneDarkHighlightStyle, oneDarkTheme } from '@codemirror/theme-one-dark'

Now I import the theme and pass oneDarkHighlightStyle to highlightTree().

I get most of the CSS rules by calling oneDarkHighlightStyle.module.getRules():

.ͼp {color: #c678dd;}
.ͼq {color: #e06c75;}
.ͼr {color: #61afef;}
.ͼs {color: #d19a66;}
.ͼt {color: #abb2bf;}
.ͼu {color: #e5c07b;}
.ͼv {color: #56b6c2;}
.ͼw {color: #7d8799;}
.ͼx {font-weight: bold;}
.ͼy {font-style: italic;}
.ͼz {text-decoration: line-through;}
.ͼ10 {color: #7d8799; text-decoration: underline;}
.ͼ11 {font-weight: bold; color: #e06c75;}
.ͼ12 {color: #d19a66;}
.ͼ13 {color: #98c379;}
.ͼ14 {color: #ffffff;}

I realized I’m missing some styles, like the background-color of the editor etc…

I’m able to extract the missing styles by… :

  1. extracting oneDarkTheme[0].value to get the scope class → ͼo
  1. extract the rest of the styles with oneDarkTheme[1].value?.getRules();
.ͼo {color: #abb2bf; background-color: #282c34;}
.ͼo .cm-content {caret-color: #528bff;}
.ͼo .cm-cursor, .ͼo .cm-dropCursor {border-left-color: #528bff;}
.ͼo.cm-focused .cm-selectionBackground, .ͼo .cm-selectionBackground, .ͼo .cm-content ::selection {background-color: #3E4451;}
.ͼo .cm-panels {background-color: #21252b; color: #abb2bf;}
.ͼo .cm-panels.cm-panels-top {border-bottom: 2px solid black;}
.ͼo .cm-panels.cm-panels-bottom {border-top: 2px solid black;}
.ͼo .cm-searchMatch {background-color: #72a1ff59; outline: 1px solid #457dff;}
.ͼo .cm-searchMatch.cm-searchMatch-selected {background-color: #6199ff2f;}
.ͼo .cm-activeLine {background-color: #2c313a;}
.ͼo .cm-selectionMatch {background-color: #aafe661a;}
.ͼo.cm-focused .cm-matchingBracket, .ͼo.cm-focused .cm-nonmatchingBracket {background-color: #bad0f847; outline: 1px solid #515a6b;}
.ͼo .cm-gutters {background-color: #282c34; color: #7d8799; border: none;}
.ͼo .cm-activeLineGutter {background-color: #2c313a;}
.ͼo .cm-foldPlaceholder {background-color: transparent; border: none; color: #ddd;}
.ͼo .cm-tooltip {border: none; background-color: #353a42;}
.ͼo .cm-tooltip .cm-tooltip-arrow:before {border-top-color: transparent; border-bottom-color: transparent;}
.ͼo .cm-tooltip .cm-tooltip-arrow:after {border-top-color: #353a42; border-bottom-color: #353a42;}
.ͼo .cm-tooltip-autocomplete > ul > li[aria-selected] {background-color: #2c313a; color: #abb2bf;}

Now I add ͼo classname to the .cm-editor element and then this works. But extracting the missing styles this way feels very hacky and brittle to me.

@marijn Any guidance on what I’m doing here? Any APIs I should be calling instead of what I’m doing?

Thank you!

The reorganization of the highlighting code in 0.20.0 and @lezer 0.16.0 should make this a lot more straightforward. You can now run highlighting without touching any @codemirror libraries. See @lezer/highlight.

1 Like

Thank you @marijn I’ll take a look.

I’ve got this working with 0.20.

For now I’m manually copying the baseTheme styles from here (I didn’t see a public export for that):

Any plans for making the baseTheme a public export?

Why do you need the editor CSS to highlight code outside an editor?

Valid question.

Here’s what I’m doing:

I guess my thought was just to have it look as close as possible to the client-side rendered version, so it would make re-using themes easier, though that may not be necessary.

Turns out I only needed a few of the styles anyway, so I’m probably good:

@marijn Are you suggesting…
a) Might as well create my own editor wrapper around the syntax-highlighted code
or
b) I can get the baseTheme in an easier way

I believe you’re talking about a). Am I reading that right?

Yes, you don’t need any of the editor-related things, usually — highlighted code can just be a stream of spans in a white-space: pre element.

1 Like

For anybody wanting a simple way to do SSR, I published an SSR module to npm:

Thanks everybody for your help! :pray:

3 Likes

Hello.

For anyone lurking, same as I was, I found a way of fixing codemirror in the context of SSR in the Remix.run framework.

Problem here is remix clears import cache from one request to the other, so any css imported by codemirror gets unimported if the request cycle is not the common. For example, when clicking the back button on your browser, all the styles get removed.

I found a way to fix based on the code from @jamischarles with very few lines of code.
Assuming view is your instance of EditorView, run this code when creating your editor.

    const css = (view as any).styleModules.map((t: any) => t.getRules()).reverse().join('\n')
    
    const existingHeadEl = document.head.querySelector('style')
    if (!existingHeadEl) {
      const style = document.createElement('style')
      style.id = 'codemirror-css'
      style.innerHTML = css
      document.head.appendChild(style)
    }

Apart from this, I also had to copy all the styles from baseTheme$1 in codemirror source code and feed it to a EditorView.baseTheme extension in order to get the base styles included in styleModules, but with this I can re-add the css styles whenever the editor instance is created.

Let me know if you think there is any better way to do it.