A quest for a "perfect" Markdown editor

I am looking for a tool to preview contents of a Markdown file, as I edit it. After checking plenty of existing tools, all of them appear to have different shortcomings:

(I got to be honest I haven’t check each and every Markdown editor out there)

I’ve also tried using an online “Demo” page for Markedjs, only to have lost a couple of hours of writing when my Chrome crashed :sob: But that’s a lesson for me, I guess.

Instead of using a dedicated “Markdown editor” tool for this, I’d like to use:

  • plain text .md files on my hard drive,
  • my editor of choice, e.g. neovim or Zed, or even a TextEdit on macOS,
  • a “small” server that would notice changes to the file, then render an .html out of it and cue browser to reload the page (see next item),
  • a browser, that would render an .html version of the .md file.

I’ve largely achieved this by using a node HTTP server, with a combination of marked, highlight.js and socket.io libraries:

I know this is ugly
const http = require('http');
const fs = require('fs');
const socketIo = require('socket.io');

const { Marked } = require('marked');
const { markedHighlight } = require('marked-highlight');
const hljs = require('highlight.js');

const server = http.createServer((req, res) => {
  if (req.url === '/') {
    fs.readFile('./index.html', 'utf8', (err, content) => {
      if (err) {
        res.writeHead(500, { 'Content-Type': 'text/plain' });
        res.end('Internal server error');
        return;
      }
      res.writeHead(200, { 'Content-Type': 'text/html' });
      res.end(content);
    });
  } else if (req.url === '/socket.io/socket.io.js') {
    res.writeHead(200, { 'Content-Type': 'application/javascript' });
    res.end(socketIo.clientSource);
  } else if (req.url === '/highlight.js/styles/github.css') {
    res.writeHead(200, { 'Content-Type': 'text/css' });
    res.end("node_modules/highlightjs/styles/obsidian.css")
  }
});

const io = socketIo(server);

let htmlContent = '';

const updateHtmlContent = () => {
  const mdContent = fs.readFileSync('post-1.md', 'utf8');

  const marked = new Marked(
    markedHighlight({
      langPrefix: 'hljs language-',
      highlight(code, lang) {
        const language = hljs.getLanguage(lang) ? lang : 'plaintext';
        return hljs.highlight(code, { language }).value;
      }
    })
  );

  htmlContent = marked.parse(mdContent);
};

// Initial read
updateHtmlContent();

io.on('connection', (socket) => {
  console.log('Client connected');

  socket.emit('updateContent', htmlContent);

  socket.on('disconnect', () => {
    console.log('Client disconnected');
  });
});

fs.watchFile('article.md', () => {
  console.log('article.md changed');
  updateHtmlContent();

  io.emit('refreshContent');
});

server.listen(3000, () => {
  console.log('Server running on http://localhost:3000/');
});

With the above, I’m able to edit the file locally and see it’s preview in Safari. This works good enough, but as the length of article grows, there’s a noticeable lag between saving the file and the contents appearing on the web page. Perceivably, the lag comes from 2 sources:

  • there’s some kind of delay between when the file is changed, and when the change is “noticed”,
  • on top of that, there’s a full page re-render needs to happen every time the new contents is about to appear on page.

Because in Elixir ecosystem there exist tools like earmark, phoenix_live_view and phoenix_live_reload - couldn’t the above script in node be implemented much more optimally? E.g.:

  1. (server) on start,
    1. load .md file from disk,
    2. convert .md to .html, “memoize” it,
    3. serve .html,
    4. establish a WebSocket connection with browser,
  2. (server) listen for changes on .md file; when a change occurs:
    1. convert .md to .html,
    2. figure out diff between existing .html by using a previous “memoized” version of .html,
    3. send diff over the WebSocket connection to browser,
    4. let JavaScript in browser apply up the diff.

Conceptually, it would look like this:

Or more precisely, like this:

I don’t know a lot about LiveView, and not sure where to start about this one. I’ve noticed that LiveView actually is dependent on Phoenix itself, so actually a full-blown phoenix project is likely be needed here, but I am ok with that.

The benefits of such a server as I see it:

  • all .md files are local, so chances of loosing changes is small,
  • the preview generation is blazing fast,
  • I’d be using tools from Elixir ecosystem, and potentially contribute back (for example if earcut is lacking support for some things from CommonMark).

Where would one start on something like this? :thinking:

:information_source: someone seems to have taken a stab at a similar idea:

The difference appears to be that they offer a on-page editor, as opposed to editing the file on the disk

From what I know, the actual html diff is a internal part of the liveview system and it triggers based on the change of the assignments. What I want to say with this is that currently liveview (from what I know) doesn’t have a system to generate html diffs from a full data-source directly, the way it generates them is an internal implementation that laverages on data(assigns).

If my assumption is correct, you will not be able to use liveview directly to patch the frontend html, because the markdown parser generates an entire html document, not some partial changes that liveview would understand how to operate with.

1 Like

I send whole markdown from client to server with a small debounce (100ms) after key strokes, and render the whole markdown server side and send everything back. It works reasonably well for:

  • Smallish text, like a few paragraphs
  • Desktop browsers. Not because mobile is slow; but because the text input system on mobile is complex and the re-rendering of the textarea mess up the input states

Have you seen the MarkdownPreview plugin for nvim? It’s pretty good.

1 Like

Logseq is an obsidian alternative.

Editor plugin also works great.