How to build a GitHub-style Markdown Editor

2024-07-19 The author profile picture Peter Ullrich

Through the course of building Indie Courses, I have tried many What-You-See-Is-What-You-Get (WYSIWYG) editors. I tried Trix, TipTap, Quill, CKEditor, StacksEditor, Lexical, and some I’ve forgotten. All of them worked, but sadly none of them supported my main use-case well: reliable code syntax highlighting.

The most promising one was ProseMirror and its code-focused sibling, CodeMirror. However, both require quite a bit of custom JavaScript, and eventually, I decided against them in favour of a much simpler alternative: A GitHub-style Markdown Editor.

Here’s how I built it.

GitHub’s Editor

If you inspect the GitHub editor, you’ll see a few nested Web Components like this:

<!-- divs and inputs were omitted for brevity -->
<tab-container>
    <markdown-toolbar></markdown-toolbar>
    <file-attachment>
        <text-expander>
            <slash-command-expander>
                <textarea></textarea>
            </slash-command-expander>
        </text-expander>
    </file-attachment>
</tab-container>

GitHub uses a lot of Web Components - thank you to Konnor for pointing this out to me - but the underlying input field is still a plain textarea. The Web Components only add optional functionality like tab navigation, text manipulation (i.e. bold, italic, etc.), file uploads, text expansion for special symbols like @ and #, and slash commands.

I decided to use a minimised combination of textarea + markdown-toolbar. The markdown-toolbar gives basic text formatting options, which was good enough for my use case.

I also decided to use GitHub’s paste-markdown library because that makes it easier to copy and paste text into the Markdown editor. For example, if you copy and paste text that contains links, paste-markdown would automatically convert them to [title](http://link.com) markdown.

The Editor

First, I installed the two libraries using NPM:

npm install --save @github/markdown-toolbar-element @github/paste-markdown

Next, I created a textarea field and added the markdown-toolbar element to it:

# This is Heex, which is Phoenix's template language.
# The pure HTML version of this would look very similar.
<.form for={@form} phx-change="validate">
    <markdown-toolbar for={@form[:content].id}>
        <md-bold><.icon name="fa-bold-solid" /></md-bold>
        <md-italic><.icon name="fa-italic-solid" /></md-italic>
    </markdown-toolbar>
    <.input field={@form[:content]} type="textarea" phx-hook="ParseHTML" />
</.form>

This minimal setup already allows us to write Markdown in the textarea and to style it using the buttons rendered by the markdown-toolbar.

I used FontAwesome icons here because Heroicons didn’t have good icons for the bold and italic buttons. This blog post by Andrew Timberlake explains how to add them to your Phoenix application.

The ParseHTML Hook

I used a Phoenix Hook to add the parse-markdown observer to the textarea. This is the entire hook and how I import it into my Phoenix application:

// assets/js/hooks/parse-html.js
import { subscribe } from "@github/paste-markdown";

export default {
  mounted() {
    subscribe(this.el);
  },
};

// assets/js/app.js
import "@github/markdown-toolbar-element";
import ParseHTML from "./hooks/parse-html";

let Hooks = {};
Hooks.ParseHTML = ParseHTML;

let liveSocket = new LiveSocket("/live", Socket, {
  // ...
  hooks: Hooks,
});

This is all it takes to convert text pasted as HTML into valid Markdown automatically. Neat!

The Preview

GitHub’s editor allows you to write Markdown, but also to preview it. Previewing the Markdown requires us to convert the Markdown into HTML and to render it. I used the fabulous MDEx library for the conversion because its underlying Rust library comrak supports most of GitHub’s special Markdown format, like task lists and tables.

I added the preview as a new tab, just like GitHub’s editor does. Here’s the full code:

<.form for={@form} phx-change="validate">
  <div>
    <button phx-click="show_write" phx-value-show="true">Write</button>
    <button phx-click="show_write" phx-value-show="false">Preview</button>
  </div>

  <div :if={@show_write}>
    <%!-- markdown-toolbar and textarea --%>
  </div>

  <div :if={!@show_write}>
    <.input type="hidden" field={@form[:content]} />
    <%= @form[:content].value |> markdown_to_html() |> raw() %>
  </div>
<.form>

The template above renders either the form or the preview, depending on the @show_write variable. The two buttons toggle this variable and, therefore, the views. You could also use a client-side JS.toggle/1 command on the buttons to show and hide the views, but I wanted to hide other elements as well, which is why I decided to use a server-side variable for toggling the views.

Note the hidden input for the content in the preview. This is necessary because the user might submit the form while previewing the content. Without a hidden input in the preview, the form would not submit the current content and we would not update it in the database.

This is how the fully-styled editor looks:

The markdown editor

The LiveView

Now, let’s look at our LiveView. These are the two functions relevant to the editor:

defmodule MyLiveView do
  # mount/3, etc. omitted for brevity

  def handle_event("show_write", %{"show" => value}, socket) do
    {:noreply, assign(socket, :show_write, value == "true"}
  end

  def markdown_to_html(markdown) do
    markdown
    |> MDEx.to_html!(
      features: [syntax_highlight_theme: "github_dark"],
      extension: [
        strikethrough: true,
        underline: true,
        tagfilter: true,
        table: true,
        autolink: true,
        tasklist: true,
        footnotes: true,
        shortcodes: true
      ],
      parse: [
        smart: true,
        relaxed_tasklist_matching: true,
        relaxed_autolinks: true
      ],
      render: [
        github_pre_lang: true,
        escape: true
      ]
    )
  end
end

The interesting function here is the markdown_to_html/1 function in which I use MDEx to convert the markdown to HTML. I enabled a bunch of extensions like tasklist, autolink, and table to support almost all GitHub features.

I also enabled code syntax highlighting based on the GitHub Dark theme. This feature uses Autumn to add inline styles to the code block. This means that I don’t have to use highlight.js or Prism to highlight the code client-side, which reduces my dependency on NPM further.

And that’s all you need to build a GitHub-style Markdown Editor!

Rendering with raw/1

You’ve probably spotted that I’ve used raw/1 to render the HTML in the Heex template. Using raw/1 can be a safety issue because it does not escape potentially malicious links and scripts from your user input, which makes you vulnerable to HTML injections. In this case, MDEx has got our back, thankfully.

The underlying library comrak escapes the Markdown and its HTML output for us. So, the HTML output it gives us should be safe. Still, no course on Indie Courses gets published without me reviewing it, which adds another layer of protection.

Conclusion

And that’s it! If you enjoyed this article, consider hosting your video course with us! If you want to be notified of future blog posts, subscribe to our newsletter below. Until next time!

Sign up for future Articles