Client-side Blog Rendering

2021-12-25

I was thinking about different blogging systems, and considered an approach that aimed to be as close to zero-hassle as possible. I didn't end up using this approach, but it was still an interesting journey worth documenting, I think.

There are two main sources of hassle for any blog: generating the HTML and deploying it (static blogs), or maintaining a server-side application (like Wordpress or Ghost) that generates the HTML there. For my personal and professional notes, I leverage Tiddlywiki which avoids both of these by pushing all the rendering to the browser. I think this approach has merit, but Tiddlywiki is a bit heavy (3MB+ baseline) for a static site...I prefer something a bit lighter, if possible. So perhaps there's a way to combine a few smaller tools to accomplish something similar.

With that goal in mind, the prototype is built on Marked (47k), with some help from Water.css (23k) for styling and highlight.js (112k) to handle code highlighting. Like Tiddlywiki, this approach pushes rendering to the browser, accomplishing the goal of a zero-hassle website: there is no build step, and there's also no server-side logic to update or maintain. This setup allows a very similar workflow to the workflow I adopted when working with Markdeep.

These three tools need to be lashed together somehow in an HTML file to make it all work. I prefer to have the code bring in resources from the same domain it is being hosted from, so water.css and solar-flare.css (a 1.4k base16 theme from highlight.js that goes well with Water) are both referenced using relative links, as is highlight.js and marked.js. The resulting head element looks like this:

<head>
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" href="water.min.css">
  <link rel="stylesheet" href="solar-flare.min.css">
  <script src="highlight.min.js"></script>
  <script src="marked.min.js"></script>
  <style>
    body { font-size: 125%; font-family: serif;}
  </style>
</head>

Marked is a JavaScript function that accepts markdown-formatted text as a string, and returns the corresponding HTML. In our HTML document, we need to have someplace we get the markdown-formatted-text from, and someplace to put the rendered HTML.

Next, we need a container for the Markdown. The challenge here is to have the container allow me to write whatever I want, including HTML. The code above would have to be escaped normally, since it's HTML inside of HTML. I could escape HTML every time I want to include it, but I instead found that I could put all the markdown inside of an <xmp> tag. There are conflicting reports from developers about how dangerous this tag is, given it was deprecated in 1995. After some research, I learned that a <textarea> tag can contain HTML as well, so I use that instead:

<textarea readonly id="md" style="height: 100%;">
<!-- Markdown goes here -->
</textarea>

The next element is the where the rendered markdown will be displayed. It's just a div with an ID of content:

<div id="content"></div>

Then, all that remains is to initialize Marked (enabling syntax highlighting), and render the content:

<script src="marked.min.js"></script>
<script>
  document.getElementById('nojs').style.display = 'none';
  document.getElementById('md').style.display = 'none';
  marked.setOptions({
    renderer: new marked.Renderer(),
    highlight: function(code, lang) {
      const language = hljs.getLanguage(lang) ? lang : 'plaintext';
      return hljs.highlight(code, { language }).value;
    },
    langPrefix: 'language-',
    pedantic: false,
    gfm: true,
    breaks: false,
    sanitize: false,
    smartLists: true,
    smartypants: false,
    xhtml: false
  });
  document.getElementById('content').innerHTML = marked.parse(document.getElementById('md').textContent);
</script>

Adding Section Links

One feature that's missing is anchor links for each heading. Linking is very powerful, and while this blog format makes it very easy to write, it's not very useful if it doesn't allow linking to specific bits of content.

Marked is pretty darn flexible, and helpfully adds id attributes to headings so they can be referenced. However, it doesn't create any visible links to those anchors, making them much less useful. It turns out that Marked has an allowance for the renderer to be customized function-by-function, though, so it's possible to only override the header function to not only create the id, but also add a link. After reading through the Marked documentation and experimenting, this approach seems to work well enough:

const renderer = {
  heading(text, level, raw, slugger) {
    const slug = slugger.slug(text);
    return `
            <h${level}>
              <a id="${slug}" href="#${slug}">&#167;</a>
              ${text}
            </h${level}>`;
  }
};

The challenge here was noticing the second two arguments to the heading function: raw and slugger. Marked's documentation on the Renderer has an example that elides these arguments, so I wasn't initially aware of them. Farther down the page, however, under Block-level renderer methods, it mentions the full signature with all four parameters.

The interesting parameter for this exercise is the fourth: slugger. It's an object with a single function slug, which accepts the text of the heading. This is vital because the function doesn't provide access to the slug Marked would have generated, but it does allow the code to use slugger to generate it, and then reuse it for the link.

After defining the new renderer, Marked needs to be configured to use it.

marked.use({ renderer });

The use method merges any overridden functions into the main renderer, so the new logic will be used during generation.

Potential Improvements

There are some other rough edges with this approach:

  • No JavaScript: one challenge is to have the site fall back to showing the Markdown in case the browser doesn't have JavaScript. To implement this, the <textarea> is shown by default, and hidden with JavaScript. The "no JavaScript" message, stored in a div with an ID of nojs is hidden in the same way. While this works well-enough on a mainstream, desktop browser with no JS, it presents problems on text-only browsers like links and eww. links shows a tiny textarea that makes the markdown unreadable, while eww shows nothing at all. Even on Bromite (my mobile browser), if I disable JavaScript, the scrolling of the textarea causes conflicts with pull-to-refresh. I'd love to remove the textarea scroll altogether, but I haven't yet been able to find a good no-JavaScript solution for sizing the textarea to the height of the content.
  • I really like RSS, and this approach simply doesn't provide an RSS feed. I don't see a good approach here: if I want to avoid a preprocessing step, and I want to avoid a dynamic content, that leaves only the client. That suggests a kind of a crazy idea: JavaScript could parse the code for the page and generate a feed...except RSS readers don't execute JavaScript when fetching the feed, so that approach falls flat right away. I could add this to a "build" step, but then we're back to a build step.