Image Name

Extend Markdown Capabilities on Static Sites with Simple, Inline JavaScript apps (using Hexo & Vue.js)

March 28, 2018 by Ken Kaczmarek

“Can you invoke a small JavaScript app using only Markdown syntax?”

That’s the question we had when we began to write the docs for our data feed API. We wanted a live, runnable code snippet, without adding raw JavaScript to our Markdown files.


TL;DR: Yes. 💥 Check out our Markdown Components repo on GitHub.

Here’s an example of a live snippet in Markdown (Flex.io JS SDK code is invoked upon click of the RUN button):

Dynamic to Static and Back Again

The story of moving to a static website is probably worth a post of its own, but let’s just say we’re happy Hexo users. 😊

As with most static site generators, we’re using Markdown files for the bulk of our content. We also happen to use Vue.js and, while looking through their docs, we found their tiny, inlined Vue.js apps to be particularly nice:


Vue.js docs Markdown button gif

Eureka!

One of the great features of Markdown is that, in a pinch, you can go off-road with raw HTML (including JavaScript). Taking a quick peek at the source for the docs we saw that, indeed, they’re simply injecting the entire Vue.js example app inline in their Markdown file (see the % raw % tag below):

### Handling User Input

To let users interact with your app, we can use the `v-on` directive to attach event listeners that invoke methods on our Vue instances:

``` html
<div id="app-5">
<p>{{ message }}</p>
<button v-on:click="reverseMessage">Reverse Message</button>
</div>
```
``` js
var app5 = new Vue({
el: '#app-5',
data: {
message: 'Hello Vue.js!'
},
methods: {
reverseMessage: function () {
this.message = this.message.split('').reverse().join('')
}
}
})
```

{% raw %}
<div id="app-5" class="demo">
<p>{{ message }}</p>
<button v-on:click="reverseMessage">Reverse Message</button>
</div>
<script>
var app5 = new Vue({
el: '#app-5',
data: {
message: 'Hello Vue.js!'
},
methods: {
reverseMessage: function () {
this.message = this.message.split('').reverse().join('')
}
}
})
</script>
{% endraw %}

Note that in this method we update the state of our app without touching the DOM - all DOM manipulations are handled by Vue, and the code you write is focused on the underlying logic.

This is certaily a workable solution, however it has some serious drawbacks if you’re adding more than a couple scripts into your docs:

  • Scripts must be written twice in Markdown (once to publish and once to run)
  • Raw, inline code in Markdown is inelegant and hard to debug
  • Copy/Paste means you can’t centrally update the script
  • Non-technical contributors would require lots of dev hand-holding

Meh.

Enter Hexo Filters and Vue.js

The more elegant weapon for a civilized age? Extend your Markdown with a Hexo filter:

A filter is used to modify some specified data. Hexo passes data to filters in sequence and the filters then modify the data one after the other.

This was exactly what we needed.

We created a Hexo filter that modified the Markdown before Hexo’s Markdown parser modified it. This allowed us to inject a mini JavaScript app into any Markdown file anywhere on our website. We simply triggered the filter using standard code block syntax followed by a reference tag; in this case a code block with the tag run-js.


Here’s a “Hello, World!” example in Markdown syntax with the run-js tag (PLEASE NOTE: For the markdown code block examples below, we’ve included a space after the first backtick like this — ` `` — in order to display the code):

` ``run-js
alert('Hello, World!')
```

And, here’s the way the Markdown is now rendered by Hexo; click the RUN button to fire the alert:


Here’s the Hexo filter code which replaces the above code snippet with the code necessary to invoke the Markdown component (created with Vue.js). Here’s a quick overview of the script:

At the top of the file, we have the regex which describes how to match the code block so we can inject our component. In this particular case, our Markdown code block is tagged with run-js after the three backticks:

var regex = /(\s*)(```)\s*(run-js)\s*\n?([\s\S]+?)\s*(\2)(\n+|$)/g

Following this, there are a number of helper functions that are relatively self-explanatory. The ignore function tell Hexo not to run this filter on the specified file types. The getId(idx) function returns a unique ID that we can use for the <div> tag we’ll insert (the target of our Vue.js app). The parseContent function strips out any JSON options that are specified after the backticks and component tag name. And finally, the htmlEscape function makes sure that we escape any special characters in our HTML output.

At the bottom of the file, we finally come to the render function, which Hexo will call whenever it encounters a match to the regex at the top of the file:

function render(data) {
if (!ignore(data)) {
var idx = 0
data.content = data.content.replace(regex, function(raw, start, startQuote, lang, content, endQuote, end) {
var id = getId(idx)
var res = parseContent(content)

//return start + debug(res) + end

return '' +
start +
'{% raw %}' +
getMarkupInclude(id, res.code, res.options) +
getScriptInclude(id, idx++) +
'{% endraw %}' +
end
})
}
}

hexo.extend.filter.register('before_post_render', render, 9)

In the code above, we ignore non-matching file types so that we perform our regex replace (data.content = data.content.replace(regex, function(...)) only on the file of our choosing. To sum up, this function:

  • creates a unique ID for the target <div> with getId(idx)
  • splits the content into code and an options object with parseContent(content)
  • returns a new string which includes the markup with our target <div> for the app (getMarkupInclude(id, res.code, res.options)) and the <script> where we actually instantiate the mini Vue.js app (getScriptInclude(id, idx++))

And finally, we find the hook which tells Hexo to register this filter and use it before each page is rendered (i.e., before the Markdown is converted to HTML).

OK, now that we have a basic filter that can run any JavaScript from Markdown, let’s see what else we can do.

Example: Create Tabbed Snippets

When providing code snippets, sometimes it’s useful to offer a tabbed interface, where you can quickly show different flavors of code without wasting vertical space. Tabs aren’t a native HTML component and even if they were, the Markdown spec certainly doesn’t allow for them. Tabs are almost always something that are created using a small bit of JavaScript.

So, we created another Hexo filter for tabs in Markdown. In this case, we’re triggering the filter by a triple-left bracket followed by tabs.


Here’s the Markdown syntax we used (PLEASE NOTE: For ‘bracket’ examples below, we’ve included a space after the first left bracket like this — [ [[ — in order to display the code):

[ [[tabs
(((tab:cURL
```bash
curl https://httpbin.org/base64/aGVsbG8gd29ybGQNCg%3D%3D
```
)))

(((tab:Javascript
```javascript
console.log("Hello World!");
```
)))

(((tab:Logo
```logo
TO HELLO
PRINT [Hello world]
END
```
)))
]]]

And here’s the way the Markdown is rendered; click the tabs to see the snippets:


curl https://httpbin.org/base64/aGVsbG8gd29ybGQNCg%3D%3D

console.log("Hello World!");

TO HELLO
PRINT [Hello world]
END


Here’s the Hexo filter code for the tabs. Again, you’ll see there are some pretty common elements to these scripts including the htmlEscape, ignore, getId, getMarkupInclude and getScriptInclude functions.

The main differentiation comes in that we have to perform a second regex to build up the HTML markup necessary for each of the tabs to render properly This is done in the parseContent function:

function parseContent(str) {
var tab_regex = /(\s*)(\(\(\() *(tab) *\n?([\s\S]+?)\s*(\)\)\))(\n+|$)/g

var tabs = {
headings: '',
contents: ''
}

function addHeading(title) {
tabs.headings += '' +
'<div><a class="js-tabs__title" href="#">' +
title +
'</a></div>'
}

function addTabContents(contents) {
tabs.contents += '' +
'<div class="js-tabs__content">' +
contents +
'</div>'
}

...

Example: Create Snippets that Perform Actions

OK, let’s get back to implementing runnable snippets.

We’ll show two snippet components below: 1) a copy button and 2) a run button. To demonstrate, we’ll be using the render task from our API, which will return a screenshot of a website.

Snippet #1: Add a Copy Button

For our docs, we wanted to enable users to copy snippets and paste them into their own code. To do this, we needed:

  • A copy button.
  • Additional boilerplate text that reminded users to insert their API key.

Custom hexo filter to the rescue! We’re triggering the filter with run-js tag (as shown in the Hello World example above) and specifying that we only want a COPY button to be included (by specifying "buttons": ["copy"]) when the component is rendered.


Here’s the Markdown syntax:

` ``run-js:{ "buttons": ["copy"], "copy-prefix": "\/\/ insert your API key here to use the Flex.io JS SDK\n\/\/ Flexio.setup('YOUR_API_KEY')\n" }
Flexio.pipe()
.render('https://www.google.com')
.run(function(err, response) {
console.log(response.text)
})
```

And this is how Hexo renders it:

When you click the COPY button and then paste it into a text file, you’ll get this:

// insert your API key here to use the Flex.io JS SDK
// Flexio.setup('YOUR_API_KEY')

Flexio.pipe()
.render('https://www.google.com')
.run(function(err, response) {
console.log(response.text)
})

Here’s the Hexo filter code again and here’s the Vue.js component code. This is where adding options (or props in Vue.js) to your component comes in.

We’ve added both a buttons prop as well as copy-prefix and copy-suffix props to our component to make them configurable directly in Markdown. The former allows us to specify which buttons we’d like to include with our code examples and latter two allow us to customize what text is prepended and/or appended to the code snippet when it’s copied.

Snippet #2: Call an API Function

Finally, let’s create a snippet example like we saw at the beginning of this post—the one that runs the API call from Markdown and returns the result. For this one, we’ll add two more modifications from the previous example:

  • By default, we’ll have both COPY and RUN buttons
  • Because the API call may provide different results (string, image, etc), we’ll add a response-type option (again, this is another prop on the code example component).

To do this, we created our own internal Hexo filter specifically for running our Flex.io JS SDK code. This filter extends the functionality of the ‘run-js’ filter we’ve been working with in this post. For the our internal filter, we trigger with flexio-js and specify the type of response using "response-type": "image", since this call renders an image from a URL. We also automaticaly preprend our boilerplate to the COPY button, since it’s the same for every snippet.


Here’s the Markdown syntax:

` ``flexio-js:{ "response-type": "image" }
Flexio.pipe()
.render('https://www.flex.io')
.run(function(err, response) {
// image returned as 'response.blob' or 'response.buffer' on node.js
})
```

And this is how Hexo renders it (click the RUN button to fire the API call):


PLEASE NOTE: Providing the response type as an option added quite a bit of complexity to our internal filter and is specific to how our JS SDK works. Given its unique nature, we opted not to include this functionality in the Run Javascript component, but happy to discuss or make public if there is interest.

Contributions More than Welcome

We hope you found this Markdown concept helpful. Once we make a component available, we’ve found it super-easy for anyone (technical or not) to use in Markdown files. Feel free to fork our Markdown Components repo and have at it.

We can also envision plenty of ways to extend this idea with other custom components, so if you’d like to contribute to the Markdown Components GitHub repo, we’ll be happy to expand it.

Finally, we’re most familiar with Hexo, but believe that this methodology should be possible with other static site generators too. If anyone knows similar methods with other frameworks and would like to share, please let us know and we’ll be happy to update this post and the README file accordingly.

If you have any questions, please feel free to chat with us (bottom right) or shoot us a note to support@flex.io. Thanks!