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: