codemirror

The CodeMirror 6 editor, packaged as Rails assets.

Point being: you can drop a real code editor into a Rails app without adding Node, a bundler, or a build step. Install the gem, import from "codemirror" in your JavaScript, done. It's built for the Propshaft + import map setup that ships with Rails 7.1 and 8.

The old codemirror-rails gem stopped at CodeMirror 5 years ago. This is a fresh start for CodeMirror 6.

Install

# Gemfile
gem "codemirror"
bundle install

Requires Ruby 4.0+. That's the whole setup. The gem registers its assets with the pipeline and adds its pins to your import map automatically, so there's nothing to configure. If you want to double-check the pins landed:

bin/importmap json | grep codemirror

You'll want propshaft and importmap-rails, which are already there in a default Rails app.

Usage

Import from the usual CodeMirror package names, same as you would with npm:

// app/javascript/editor.js
import { EditorView, basicSetup } from "codemirror"
import { javascript } from "@codemirror/lang-javascript"
import { oneDark } from "@codemirror/theme-one-dark"

export function mountEditor(parent, doc = "") {
  return new EditorView({
    doc,
    parent,
    extensions: [basicSetup, javascript(), oneDark],
  })
}

Most of the time you'll want this behind a Stimulus controller, syncing the editor into a hidden field so the form actually submits the code:

// app/javascript/controllers/code_editor_controller.js
import { Controller } from "@hotwired/stimulus"
import { EditorView, basicSetup } from "codemirror"
import { EditorState } from "@codemirror/state"
import { javascript } from "@codemirror/lang-javascript"
import { oneDark } from "@codemirror/theme-one-dark"

export default class extends Controller {
  static targets = ["input"]

  connect() {
    this.view = new EditorView({
      state: EditorState.create({
        doc: this.inputTarget.value,
        extensions: [
          basicSetup,
          javascript(),
          oneDark,
          EditorView.updateListener.of((update) => {
            if (update.docChanged) {
              this.inputTarget.value = update.state.doc.toString()
            }
          }),
        ],
      }),
      parent: this.element,
    })
  }

  disconnect() {
    this.view?.destroy()
  }
}
<%# app/views/snippets/_form.html.erb %>
<div data-controller="code-editor">
  <%= form.hidden_field :body, data: { code_editor_target: "input" } %>
</div>

What's in the box

The core codemirror package (basicSetup, minimalSetup, EditorView), the lower-level pieces it's built on (@codemirror/state, view, language, commands, search, autocomplete, lint), the One Dark theme, and a bunch of languages:

import { javascript } from "@codemirror/lang-javascript"  // + TypeScript, JSX
import { html }       from "@codemirror/lang-html"
import { css }        from "@codemirror/lang-css"
import { json }       from "@codemirror/lang-json"
import { python }     from "@codemirror/lang-python"
import { markdown }   from "@codemirror/lang-markdown"
import { sql }        from "@codemirror/lang-sql"
import { xml }        from "@codemirror/lang-xml"
import { rust }       from "@codemirror/lang-rust"
import { cpp }        from "@codemirror/lang-cpp"
import { java }       from "@codemirror/lang-java"
import { php }        from "@codemirror/lang-php"
import { yaml }       from "@codemirror/lang-yaml"
import { go }         from "@codemirror/lang-go"
import { vue }        from "@codemirror/lang-vue"
import { angular }    from "@codemirror/lang-angular"
import { sass }       from "@codemirror/lang-sass"
import { less }       from "@codemirror/lang-less"
import { wast }       from "@codemirror/lang-wast"
import { liquid }     from "@codemirror/lang-liquid"

Every package is pinned individually, so nothing loads until you import it. Need a keymap or a specific extension? Those specifiers work too:

import { keymap } from "@codemirror/view"
import { indentWithTab } from "@codemirror/commands"

If your language or extension isn't here, it's usually a one-line addition to build/package.json followed by a rebuild — see below, or open an issue.

Working on the gem

git clone git@github.com:desheikh/codemirror.git
cd codemirror
npm --prefix build install
bundle install

The JavaScript is generated, not hand-written. build/build.mjs reads the package list in build/package.json and writes both the modules and the pins:

npm --prefix build run build        # -> app/assets/javascripts/codemirror/ + config/importmap.rb
MINIFY=0 npm --prefix build run build   # unminified, for reading/debugging

There are two checks. check links the whole module graph the way a browser would and builds a real editor state, which catches broken imports and the duplicate-singleton problem. The Ruby tests make sure the pins and the shipped files never drift apart.

npm --prefix build run check
bundle exec rake test

Bumping CodeMirror

npm --prefix build run update

This pulls the latest of every bundled package, rebuilds everything, and points Codemirror::CODEMIRROR_VERSION at the new release (resetting Codemirror::BUILD to 0 if the CodeMirror version actually changed). Look over the diff, run the two checks, and you're good.

Versioning

The gem version follows the bundled CodeMirror version, with one extra number for our own builds on top of it:

6.0.2.0, 6.0.2.1, ...   =   <codemirror version>.<build>

CODEMIRROR_VERSION and BUILD live in lib/codemirror/version.rb. In practice the build number does most of the moving, since upstream rarely bumps the umbrella codemirror package even when the individual pieces change.

Releasing

Releases are automated. Push a commit that bumps the version to master and the release workflow does the rest: it notices Codemirror::VERSION isn't on RubyGems yet, then builds, pushes, tags vX.Y.Z.B, and cuts a GitHub release. Merges that don't change the version are ignored, so there's no harm in it running on every push.

So a normal release is just:

  1. npm --prefix build run update (or edit build/package.json and rebuild), and commit the regenerated assets.
  2. Bump Codemirror::BUILD if it's a new build for the same CodeMirror version.
  3. Update CHANGELOG.md.
  4. Merge to master.

License

MIT, same as CodeMirror itself. See LICENSE.