AsciiDoc content in a Single Page Web App

import('mycontent.adoc')

How to get the content on a website? Separating it from the technical stuff sounds like a good practice for distraction free writing and focused coding. Can an import statement do the trick to load the content from a markup file?

This blog post explores how content written in the markup language AsciiDoc can be used in a single page web application. It shows a minimal generic setup without using existing content plugins. The examples are based on webpack, NuxtJS and Vue.js. You can adapt the recipes in any project that uses webpack.

Prerequisites

When I explain technical concepts in this blog post, I assume that you’ve been working with JavaScript single page apps before. These might have been written in Angular, Vue.js or React. When you look under the hood of their CLIs, they use webpack to bundle the application.

The examples in this post use Vue.js and a static site generator NuxtJS that is built on top of Vue.js. If you are using Angular or React you’ll need to adapt the recipes to match your technology.

The moving parts

This section describes the different frameworks that work together. They load the content from an AsciiDoc file and present them on a page of this blog.

The paragraphs include why I chose each piece of technology. If you skip this section now, return here later to look up technologies you want to learn more about.

webpack
Webpack provides a base layer for build automation and bundling. Without bundling, there would be no single page web app at the end. A wide and deep ecosystem provides tools and add-ons for different kinds of files and configurations in the build process. For me it’s built-in when using NuxtJS (see below).
webpack Loaders
Loaders allow you to load files from disk to use them in your application. You can stack multiple loaders, so that the output of one loader is the input of another loader. A loader allows me to load the content from the disk and process it as HTML.
Vue.js
Vue.js is a JavaScript framework that for example provides view-binding for HTML and allows component based single page web application. It calls itself “progressive”: You can start small, and when you need more functionality you can evolve your solution without changing the technology.
NuxtJS

NuxtJS builds on top of webpack and Vue.js and provides developers with a ready-to-use setup for progressive web apps and statically generated sites.

This website for example uses it to create a static site and publishes to Netlify to host it. NuxtJS helps me with a well-defined application structure, an ecosystem to pick what I need, and all the hooks and extension points I need to try out new things.

AsciiDoc and Asciidoctor
AsciiDoc is a markup language that allows structuring content in an intuitive, yet feature-rich text format. It allows for distraction-free writing of content in text files. Asciidoctor is a converter to transform AsciiDoc to HTML, PDF and other formats. It is available for Ruby, Java and JavaScript.
Semantic HTML5 Backend for Asciidoctor
Asciidoctor and its default HTML output come with ready-to-use stylesheets. The default output adds a lot of div-tags, so I looked for a lighter HTML output. The output of the “Semantic HTML5 Backend For Asciidoctor” (or html5s for short) provides me this output.
asciidoctor-highlight.js
Hightlight.js can highlight source code blocks for a lot of different languages. It can run on the client side in the browser, but then the source code will flicker as the CSS is applied after rendering the page. This modules adds it to the AsciiDoc processing at build time, the content is pre-rendered correctly with all styles.
jsdom
jsdom allows parsing of HTML in JavaScript, and manipulation using the DOM API. My first try was using regular expressions for this, but I found it to be too fragile and too hard to read.
Bulma
Bulma is a light-weight CSS framework supporting Flexbox features and is fully responsive. This website’s theme is based on Bulma. All HTML content wrapped within a tag with class content will have a reasonable default-formatting. As we’ll see later, this will match nicely with the semantic HTML output. It gives me the chance of a fresh look-and-feel with some options to change things, while keeping me on track as I am not very good at CSS. For me it is between Bootstrap (all sites look the same and being tired to look at it) and Tailwind CSS (where the ones with good CSS experience can create cool stuff).

Step-by-Step

Let’s walk through the steps one-by-one to build the fully working example.

The pipeline I’m about to build will have the following steps “bottom-up”:

  1. AsciiDoc loader to load content from a file and to convert it to HTML,
  2. HTML-to-Vue loader to transform the HTML to a Vue.js component, and
  3. Using the Vue.js component within the NuxtJS application.

Loader to convert AsciiDoc to HTML

The loader is a single JavaScript file that loads the libraries and provides a function that converts the AsciiDoc content to HTML. The following listing also performs all post-processing to the HTML output necessary to match the CSS of Bulma.

asciidoc-loader.js
const loaderUtils = require("loader-utils"),
  asciidoctor = require("asciidoctor")(),
  asciidoctorHtml5s = require("asciidoctor-html5s"), 
  jsdom = require("jsdom"),
  highlightJsExt = require('asciidoctor-highlight.js')

// Register the HTML5s converter and supporting extension.
asciidoctorHtml5s.register()
// Register server-side hightlightjs highlighting
highlightJsExt.register(asciidoctor.Extensions)

// default option
const defaultOptions = {
  safe: "unsafe",
  backend: "html5s"
}

const loggerManager = asciidoctor.LoggerManager
const memoryLogger = asciidoctor.MemoryLogger.create()
loggerManager.setLogger(memoryLogger)

memoryLogger.getMessages() // returns an array of Message
module.exports = function(content) {
  this.cacheable && this.cacheable(true)
  var params = loaderUtils.getOptions(this)
  var options = Object.assign({}, defaultOptions, params)

  var result = asciidoctor.convert(content, options) 

  memoryLogger.getMessages().forEach(message => {
    if (message.getText().match(/no callout found for/)) {
      // suppress these warnings; as a workaround for https://github.com/asciidoctor/asciidoctor/issues/3359
      return;
    }
    if (message.getSeverity() === 'DEBUG') {
      return;
    }
    console.log(message.getSeverity() + ": " + message.getText())
    const sourceLocation = message.getSourceLocation()
    if (sourceLocation) {
      console.log(sourceLocation.getLineNumber())
      console.log(sourceLocation.getFile())
      console.log(sourceLocation.getDirectory())
      console.log(sourceLocation.getPath())
    }
  })

  // parse HTML including normalizing broken HTML
  const doc = new jsdom.JSDOM("<html><body>" + result + "</body><html>").window.document;

  // minimize HTML to optimize it for Bulma 

  // remove sections wrapper
  doc.querySelectorAll("section").forEach(element => {
    element.replaceWith(...element.childNodes)
  })

  // remove div.ulist wrapper
  doc.querySelectorAll("div.ulist").forEach(element => {
    element.replaceWith(...element.childNodes)
  })

  // remove div.olist wrapper
  doc.querySelectorAll("div.olist").forEach(element => {
    element.replaceWith(...element.childNodes)
  })

  // remove empty toc-title
  doc.querySelectorAll("h2#toc-title").forEach(element => {
    if (element.textContent === "") {
      element.remove()
    }
  })

  // remove empty toc from Google's summary
  // https://developers.google.com/search/docs/advanced/crawling/special-tags
  doc.querySelectorAll("#toc").forEach(element => {
    element.setAttribute("data-nosnippet", "")
    element.setAttribute("role", "navigation")
    element.setAttribute("aria-label", "Blog post")
  })

  // rewrite callouts in listings so that copy&paste won't copy callouts
  doc.querySelectorAll("b.conum").forEach(element => {
    element.setAttribute("data-value", element.textContent)
    element.textContent = ""
  })

  result = doc.querySelector("body").innerHTML;

  return result
}
  1. load all necessary modules
  2. convert the AsciiDoc content to HTML
  3. remove some extra HTML tags not needed for Bulma

Using jsdom resolves two challenges for me:

  1. Adding missing tags if the HTML content is not well-formed.

    I used this to repair some missing closing tags for tables I encountered for the Semantic HTML5 Backend for Asciidoctor.

  2. Changing of the HTML using the DOM API.

    I used it to simplify the HTML output, so it matches the Bulma styles. In the next loader I use it to translate some HTML tags to Vue.js components.

The loader above translates this AsciiDoc snippet

This is a link:/someurl[*bold* link]!

to the HTML below:

<p>
  <strong>This</strong> is a
  <a href="/someurl">link</a>!
</p>

Loader to convert HTML to Vue.js component

This loader takes the HTML of the previous step and translates some parts of the HTML to Vue.js attributes and components. Example: By translating all local anchors to NuxtJS links, they will no longer reload the page on activation, but use the SPA mechanism to update the content. The latest version of NuxtJS will also pre-load any content once the link scrolls into view.

html-to-vue.js
jsdom = require("jsdom")

module.exports = function(content) {
  this.cacheable && this.cacheable(true)

  // parse HTML including normalizing broken HTML
  const doc = new jsdom.JSDOM("<html><body>" + content + "</body><html>").window.document;

  // rewrite all site-local anchors to nuxt-links
  // that will pre-load within the single page web app 
  doc.querySelectorAll("a[href^='/']").forEach(element => {
    const nuxt = doc.createElement("nuxt-link")
    nuxt.setAttribute("to", element.getAttribute("href"))
    element.removeAttribute("href")
    var attrs = element.attributes;
    for(var i = attrs.length - 1; i >= 0; i--) {
      nuxt.setAttribute(attrs[i].name, attrs[i].value)
    }
    nuxt.innerHTML = element.innerHTML
    element.replaceWith(nuxt)
  })

  // make all images loading lazily using native browser support
  doc.querySelectorAll("img").forEach(element => {
    // BUG: lazily loading images will not print unless the user scrolled down to the page first!
    // workaround: use beforeprint event and switch images to eager loading
    // https://bugs.chromium.org/p/chromium/issues/detail?id=875403
    element.setAttribute("loading", "lazy")
    if (element.getAttribute("alt")) {
      element.setAttribute("title", element.getAttribute("alt"))
    }
  })

  content = doc.querySelector("body").innerHTML

  // wrap content in a section
  // as Vue.js needs a single root element in the template 
  return `<template><section>${content}</section></template>`
}
  1. convert site-local anchors to nuxt-links
  2. wrap the content as a Vue.js component

This loader above translates HTML from above

<p>
  <strong>This</strong> is a
  <a href="/someurl">link</a>!
</p>

to a Vue.js component:

<template>
  <p>
    <strong>This</strong> is a
    <nuxt-link to="/someurl">link</nuxt-link>!
  </p>
</template>

Adding loaders to the NuxtJS configuration

NuxtJS bundles a ready-to-use loader pipeline. Developers can extend it by adding more steps and handle other files. The common file extension for AsciiDoc is .adoc, so we’ll teach NuxtJS how to convert these files with the two additional loaders outlined in the previous sections plus the standard Vue.js loader. As usual the order of loaders is reversed: they process files from the bottom to the top.

nuxt.config.js
// attributes for the AsciiDoc converter to configure the HTML output
const adOptions = {
  attributes: {showtitle: true, sectanchors: true, icons: "font", 'source-highlighter': 'highlightjs-ext'}
}
/* ... */
export default {
  /* ... */
  build: {
    extend(config, {isDev, isClient}) {
      config.module.rules.push({
        test: /\.adoc$/,
        use: [
          {
            loader: "vue-loader"
          },
          {
            loader: "./src/html-to-vue.js"
          },
          {
            loader: "./src/asciidoc-loader.js",
            options: adOptions
          }
        ]
      })
    }
  }
}

Using the Vue.js component

With the elements above in place, every .adoc file can be used as a component. The following loads the privacy policy from an AsciiDoc file and displays it on the page.

privacypolicy.vue
<template>
  <section class="section">
    <div class="container">
      <section>
        <div class="content"> 
          <privacy /> 
        </div>
      </section>
    </div>
  </section>
</template>
<script>
import privacy from "~/pages/privacy/content.adoc" 
export default {
  components: {
    privacy 
  },
  data: () => {
    return {
      title: "Data Protection"
    }
  }
}
</script>
  1. importing the content in AsciiDoc format
  2. declaring it as a local Vue.js component
  3. wrap the component within a Bulma content class to apply formatting
  4. use the component within the page

You can see the live page here.

Background information and discussion

What I like about this solution:

  • Keep my content in AsciiDoc, where I can write it focusing on the content.
  • Simplified hosting with a static site generator like NuxtJS
  • Flexibility to dig into CSS and optimizations with NuxtJS, therefore it doesn’t chain me to an inflexible solution.

What I don’t like about this solution:

  • While the Semantic HTML5 backend comes close to the HTML I would like to see, I needed to tweak it to match the standard Bulma CSS. I should probably learn more about CSS to make the output appear the way I want it to appear.

If you like what you’ve seen, but don’t want to start from scratch, have a look at nuxt/content (that supports Markdown, but not AsciiDoc), and this blog post with a step-by-step guide on how to set up a blog with NuxtJS and nuxt/content.

All sources you see here are from the source code of this website using AsciiDoc’s partial include syntax at build time. Therefore, all the examples you see here are tested and should work. If not, please let me know, and I’ll correct them.

Et voilà

So this is my setup to load AsciiDoc content into a Vue.js and NuxtJS website using webpack. With the respective webpack loaders a single import statement is enough to load the content as a Vue.js component. I hope you enjoyed the tour.

If you work in a Vue.js or even a NuxtJS environment, you could apply most of the recipes with minor amendments. If you use a different framework, you’ll have seen that writing loaders and wrapping content as a component is possible with a few lines of JavaScript code.

Get in contact to share your feedback or tell others about what you found here by sharing the link!


Thank you Anett aka @emsuiko for reviewing my draft version of this post: You found errors, asked the right questions and gave me hints to improve it. All remaining errors are my own.

Update 2020-11-03: Change from client-side code highlighting via nuxt-highlightjs to server-side code-highlighting via asciidoctor-highlight.js

Further reading in this blog:

Back to overview...