The Rise of the Butt-less Website

Avatar of Evan Payne
Evan Payne on (Updated on )

It seems like all the cool kids have divided themselves into two cliques: the Headless CMS crowd on one side and the Static Site Generator crowd on the other. While I admit those are pretty cool team names, I found myself unable to pick a side. To paraphrase Groucho Marx, “I don’t care to belong to any club that will have me as a member.”

For my own simple blog (which is embarrassingly empty at the moment), a static site generator could be a great fit. Systems like Hugo and Jekyll have both been highly recommended by developers I love and trust and look great at first glance, but I hit stumbling blocks when I wanted to change my theme or set up more complex JavaScript and interactions across pages. There are ways to solve both these issues, but that’s not the kind of weekend I want to have.

Besides that, I love to experiment, make new things, and I’ve got a major crush on Vue at the moment. Having a Headless CMS setup with a front-end that is decoupled from the back-end could be a great combination for me, but after 7+ years of PHP slinging with WordPress, the whole setup feels like overkill for my basic needs.

What I really want is a static site generator that will let me write a blog as a component of a larger single-page app so I have room to try new things and still have full control over styling, without the need for a database or any sort of back-end. This is a long way of telling you that I’ve found my own club to join, with a decidedly un-cool name.

Get ready for it…

The Butt-less Website

Because there’s no back-end, get it? 😶

It takes a few steps to go butt-less:

  1. Setup a single page app with Vue
  2. Generate each route at build time
  3. Create blog and article components
  4. Integrate Webpack to parse Markdown content
  5. Extend functionality with plugins
  6. Profit!

That last point has to be a part of every proposal, right?

I know it looks like a lot of steps but this is not quite as tough as it seems. Let’s break down the steps together.

Setup a single page app with Vue

Let’s get Vue up and running. We’re going to need Webpack to do that.

I get it, Webpack is pretty intimidating even when you know what’s going on. It’s probably best to let someone else do the really hard work, so we’ll use the Vue Progressive Web App Boilerplate as our foundation and make a few tweaks.

We could use the default setup from the repo, but even while I was writing this article, there were changes being made there. In the interest of not having this all break on us, we will use a repo I created for demonstration purposes. The repo has a branch for each step we’ll be covering in this post to help follow along.

View on GitHub

Cloning the repo and check out the step-1 branch:

$ git clone https://github.com/evanfuture/vue-yes-blog.git step-1
$ cd vue-yes-blog
$ npm install
$ npm run dev

One of my favorite parts of modern development is that it takes a mere thirty seconds to get a progressive web app up and running!

Next, let’s complicate things.

Generate each route at build time

Out of the box, single page apps only have a single entry point. In other words, it lives lives at a single URL. This makes sense in some cases, but we want our app to feel like a normal website.

We’ll need to make use of the history mode in the Vue Router file in order to do that. First, we’ll turn that on by adding mode: 'history' to the Router object’s properties like so:

// src/router/index.js
Vue.use(Router);

export default new Router({
  mode: 'history',
  routes: [
// ...

Our starter app has two routes. In addition to Hello, we have a second view component called Banana that lives at the route /banana. Without history mode, the URL for that page would be http://localhost:1982/#/banana. History mode cleans that up to http://localhost:1982/banana. Much more elegant!

All this works pretty well in development mode (npm run dev), but let’s take a peek at what it would look like in production. Here’s how we compile everything:

$ npm run build

That command will generate your Vue site into the ./dist folder. To see it live, there’s a handy command for starting up a super simple HTTP server on your Mac:

$ cd dist
$ python -m SimpleHTTPServer

Sorry Windows folks, I don’t know the equivalent!

Now visit localhost:8000 in your browser. You’ll see your site as it will appear in a production environment. Click on the Banana link, and all is well.

Refresh the page. Oops! This reveals our first problem with single page apps: there is only one HTML file being generated at build time, so there’s no way for the browser to know that /banana should target the main app page and load the route without fancy Apache-style redirects!

Of course, there’s an app for that. Or, at least a plugin. The basic usage is noted in the Vue Progressive Web App Boilerplate documentation. Here’s how it says we can spin up the plugin:

$ npm install -D prerender-spa-plugin

Let’s add our routes to the Webpack production configuration file:

// ./build/webpack.prod.conf.js
// ...
const SWPrecacheWebpackPlugin = require('sw-precache-webpack-plugin')
const PrerenderSpaPlugin = require('prerender-spa-plugin')
const loadMinified = require('./load-minified')
// ...
const webpackConfig = merge(baseWebpackConfig, {
  // ...
  plugins: [
  // ...
    new SWPrecacheWebpackPlugin({
      // ...
      minify: true,
      stripPrefix: 'dist/'
    }),
    // prerender app
    new PrerenderSpaPlugin(
      // Path to compiled app
      path.join(__dirname, '../dist'),
      // List of endpoints you wish to prerender
      [ '/', '/banana' ]
    )
  ]
})

That’s it. Now, when you run a new build, each route in that array will be rendered as a new entry point to the app. Congratulations, we’ve basically just enabled static site generation!

Create blog and article components

If you’re skipping ahead, we’re now up to the step-2 branch of my demo repo. Go ahead and check it out:

$ git checkout step-2

This step is pretty straightforward. We’ll create two new components, and link them together.

Blog Component

Let’s register the the blog component. We’ll create a new file called YesBlog.vue in the /src/components directory and drop in the markup for the view:

// ./src/components/YesBlog.vue
<template>
  <div class="blog">
    <h1>Blog</h1>
    <router-link to="/">Home</router-link>
    <hr/>
    <article v-for="article in articles" :key="article.slug" class="article">
      <router-link class="article__link" :to="`/blog/${ article.slug }`">
        <h2 class="article__title">{{ article.title }}</h2>
        <p class="article__description">{{article.description}}</p>
      </router-link>
    </article>
  </div>
</template>

<script>
export default {
  name: 'blog',
  computed: {
    articles() {
      return [
        {
          slug: 'first-article',
          title: 'Article One',
          description: 'This is article one\'s description',
        },
        {
          slug: 'second-article',
          title: 'Article Two',
          description: 'This is article two\'s description',
        },
      ];
    },
  },
};
</script>

All we’re really doing here is creating a placeholder array (articles) that will be filled with article objects. This array creates our article list and uses the slug parameter as the post ID. The title and description parameters fill out the post details. For now, it’s all hard-coded while we get the rest of our code in place.

Article Component

The article component is a similar process. We’ll create a new file called YesArticle.vue and establish the markup for the view:

// ./src/components/YesArticle.vue
<template>
  <div class="article">
    <h1 class="blog__title">{{article.title}}</h1>
    <router-link to="/blog">Back</router-link>
    <hr/>
    <div class="article__body" v-html="article.body"></div>
  </div>
</template>

<script>
  export default {
    name: 'YesArticle',
    props: {
      id: {
        type: String,
        required: true,
      },
    },
    data() {
      return {
        article: {
          title: this.id,
          body: '<h2>Testing</h2><p>Ok, let\'s do more now!</p>',
        },
      };
    },
  };
</script>

We’ll use the props passed along by the router to know what article ID we’re working with. For now, we’ll just use that as the post title, and hardcode the body.

Routing

We can’t move ahead until we add our new views to the router. This will ensure that our URLs are valid and allows our navigation to function properly. Here is the entirety of the router file:

// ./src/router/index.js
import Router from 'vue-router';
import Hello from '@/components/Hello';
import Banana from '@/components/Banana';
import YesBlog from '@/components/YesBlog';
import YesArticle from '@/components/YesArticle';

Vue.use(Router);

export default new Router({
  mode: 'history',
  routes: [
    {
      path: '/',
      name: 'Hello',
      component: Hello,
    },
    {
      path: '/banana',
      name: 'Banana',
      component: Banana,
    },
    {
      path: '/blog',
      name: 'YesBlog',
      component: YesBlog,
    },
    {
      path: '/blog/:id',
      name: 'YesArticle',
      props: true,
      component: YesArticle,
    },
  ],
});

Notice that we’ve appended /:id to the YesArtcle component’s path and set its props to true. These are crucial because they establish the dynamic routing we set up in the component’s props array in the component file.

Finally, we can add a link to our homepage that points to the blog. This is what we drop into the Hello.vue file to get that going:

<router-link to="/blog">Blog</router-link>

Pre-rendering

We’ve done a lot of work so far but none of it will stick until we pre-render our routes. Pre-rendering is a fancy way of saying that we tell the app what routes exist and to dump the right markup into the right route. We added a Webpack plugin for this earlier, so here’s what we can add to our Webpack production configuration file:

// ./build/webpack.prod.conf.js
// ...
  // List of endpoints you wish to prerender
  [ '/', '/banana', '/blog', '/blog/first-article', '/blog/second-article' ]
// ...

I have to admit, this process can be cumbersome and annoying. I mean, who wants to touch multiple files to create a URL?! Thankfully, we can automate this, which we’ll cover further down.

Integrate Webpack to parse Markdown content

We’re now up to the step-3 branch. Check it out if you’re following along in the code:

$ git checkout step-3

The Posts

We’ll be using Markdown to write our posts, with some FrontMatter to create meta data functionality.

Fire up a new file in the posts directory to create our very first post:

// ./src/posts/first-article.md
---
title: Article One from MD
description: In which the hero starts fresh
created: 2017-10-01T08:01:50+02
updated:
status: publish
---
Here is the text of the article.  It's pretty great, isn't it?



// ./src/posts/second-article.md
---
title: Article Two from MD
description: This is another article
created: 2017-10-01T08:01:50+02
updated:
status: publish
---
## Let's start with an H2
And then some text
And then some code:
```html
<div class="container">
  <div class="main">
    <div class="article insert-wp-tags-here">
      <h1>Title</h1>
      <div class="article-content">
        <p class="intro">Intro Text</p>
        <p></p>
      </div>
      <div class="article-meta"></div>
    </div>
  </div>
</div>
```

Dynamic Routing

One annoying thing at the moment is that we need to hardcode our routes for the pre-rendering plugin. Luckily, it isn’t complicated to make this dynamic with a bit of Node magic. First, we’ll create a helper in our utility file to find the files:

// ./build/utils.js
// ...
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const fs = require('fs')

exports.filesToRoutes = function (directory, extension, routePrefix = '') {
  function findFilesInDir(startPath, filter){
    let results = []
    if (!fs.existsSync(startPath)) {
      console.log("no dir ", startPath)
      return
    }
    const files = fs.readdirSync(startPath)
    for (let i = 0; i < files.length; i++) {
      const filename = path.join(startPath, files[i])
      const stat = fs.lstatSync(filename)
      if (stat.isDirectory()) {
        results = results.concat(findFilesInDir(filename, filter)) //recurse
      } else if (filename.indexOf(filter) >= 0) {
        results.push(filename)
      }
    }
    return results
  }

  return findFilesInDir(path.join(__dirname, directory), extension)
    .map((filename) => {
      return filename
        .replace(path.join(__dirname, directory), routePrefix)
        .replace(extension, '')
      })
}

exports.assetsPath = function (_path) {
// ...

This can really just be copied and pasted, but what we’ve done here is create a utility method called filesToRoutes() which will take in a directory, extension, and an optional routePrefix, and return an array of routes based on a recursive file search within that directory.

All we have to do to make our blog post routes dynamic is merge this new array into our PrerenderSpaPlugin routes. The power of ES6 makes this really simple:

// ./build/webpack.prod.conf.js
// ...
new PrerenderSpaPlugin(
  // Path to compiled app
  path.join(__dirname, '../dist'),
  // List of endpoints you wish to prerender
  [
    '/',
    '/banana',
    '/blog',
    ...utils.filesToRoutes('../src/posts', '.md', '/blog')
  ]
)

Since we’ve already imported utils at the top of the file for other purposes, we can just use the spread operator ... to merge the new dynamic routes array into this one, and we’re done. Now our pre-rendering is completely dynamic, only dependent on us adding a new file!

Webpack Loaders

We’re now up to the step-4 branch:

$ git checkout step-4

In order to actually turn our Markdown files into parse-able content, we’ll need some Webpack loaders in place. Again, someone else has done all the work for us, so we only have to install and add them to our config.

Update September 2018: Seif Sayed wrote in to say that the package ‘markdown-it-front-matter-loader’ is deprecated. The rest of this article still uses that, but that you probably should use markdown-with-front-matter-loader instead, and has the bonus that you don’t need a json-loader to go with it.

$ npm install -D json-loader markdown-it-front-matter-loader markdown-it highlight.js yaml-front-matter

We will only be calling the json-loader and markdown-it-front-matter-loader from our Webpack config, but the latter has peer dependencies of markdown-it and highlight.js, so we’ll install those at the same time. Also, nothing warns us about this, but yaml-front-matter is also required, so the command above adds that as well.

To use these fancy new loaders, we’re going to add a block to our Webpack base config:

// ./build/webpack.base.conf.js
// ...
module.exports = {
  // ...
  module: {
    rules: [
  // ...
      {
        test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
        loader: 'url-loader',
        options: {
          limit: 10000,
          name: utils.assetsPath('fonts/[name].[hash:7].[ext]')
        }
      },
      {
        test: /\.md$/,
        loaders: ['json-loader', 'markdown-it-front-matter-loader'],
      },
    ]
  }
}

Now, any time Webpack encounters a require statement with a .md extension, it will use the front-matter-loader (which will correctly parse the metadata block from our articles as well as the code blocks), and take the output JSON and run it through the json-loader. This way, we know we’re ending up with an object for each article that looks like this:

// first-article.md [Object]
{
  body: "<p>Here is the text of the article. It's pretty great, isn't it?</p>\n"
  created: "2017-10-01T06:01:50.000Z"
  description: "In which the hero starts fresh"
  raw: "\n\nHere is the text of the article. It's pretty great, isn't it?\n"
  slug: "first-article"
  status: "publish"
  title: "Article One from MD"
  updated: null
}

This is exactly what we need and it’s pretty easy to extend with other metadata if you need to. But so far, this doesn’t do anything! We need to require these in one of our components so that Webpack can find and load it.

We could just write:

require('../posts/first-article.md')

…but then we’d have to do that for every article we create, and that won’t be any fun as our blog grows. We need a way to dynamically require all our Markdown files.

Dynamic Requiring

Luckily, Webpack does this! It wasn’t easy to find documentation for this but here it is. There is a method called require.context() that we can use to do just what we need. We’ll add it to the script section of our YesBlog component:

// ./src/components/YesBlog.vue
// ...
<script>
  const posts = {};
  const req = require.context('../posts/', false, /\.md$/);
  req.keys().forEach((key) => {
    posts[key] = req(key);
  });

  export default {
    name: 'blog',
    computed: {
      articles() {
        const articleArray = [];
        Object.keys(posts).forEach((key) => {
          const article = posts[key];
          article.slug = key.replace('./', '').replace('.md', '');
          articleArray.push(article);
        });
        return articleArray;
      },
    },
  };
</script>
// ...

What’s happening here? We’re creating a posts object that we’ll first populate with articles, then use later within the component. Since we’re pre-rendering all our content, this object will be instantly available.

The require.context() method accepts three arguments.

  • the directory where it will search
  • whether or not to include subdirectories
  • a regex filter to return files

In our case, we only want Markdown files in the posts directory, so:

require.context('../posts/', false, /\.md$/);

This will give us a kind of strange new function/object that we need to parse in order to use. That’s where req.keys() will give us an array of the relative paths to each file. If we call req(key), this will return the article object we want, so we can assign that value to a matching key in our posts object.

Finally, in the computed articles() method, we’ll auto-generate our slug by adding a slug key to each post, with a value of the file name without a path or extensions. If we wanted to, this could be altered to allow us to set the slug in the Markdown itself, and only fall back to auto-generation. At the same time, we push the article objects into an array, so we have something easy to iterate over in our component.

Extra Credit

There are two things you’ll probably want to do right away if you use this method. First is to sort by date and second is to filter by article status (i.e. draft and published). Since we already have an array, this can be done in one line, added just before return articleArray:

articleArray.filter(post => post.status === 'publish').sort((a, b) => a.created < b.created);

Final Step

One last thing to do now, and that’s instruct our YesArticle component to use the new data we’re receiving along with the route change:

// ./src/components/YesArticle.vue
// ...
data() {
  return {
    article: require(`../posts/${this.id}.md`), // eslint-disable-line global-require, import/no-dynamic-require
  };
},

Since we know that our component will be pre-rendered, we can disable the ESLint rules that disallow dynamic and global requires, and require the path to the post that matches the id parameter. This triggers our Webpack Markdown loaders, and we’re all done!

OMG!

Go ahead and test this out:

$ npm run build && cd dist && python -m SimpleHTTPServer

Visit localhost:8000, navigate around and refresh the pages to load the whole app from the new entry point. It works!

I want to emphasize just how cool this is. We’ve turned a folder of Markdown files into an array of objects that we can use as we wish, anywhere on our website. The sky is the limit!

If you want to just see how it all works, you can check out the final branch:

$ git checkout step-complete

Extend functionality with plugins

My favorite part about this technique is that everything is extensible and replaceable.

Did someone create a better Markdown processor? Great, swap out the loader! Need control over your site’s SEO? There’s a plugin for that. Need to add a commenting system? Add that plugin, too.

I like to keep an eye on these two repositories for ideas and inspiration:

Profit!

You thought this step was a joke?

The very last thing we’ll want to do now is profit from the simplicity we’ve created and nab some free hosting. Since your site is now being generated on your git repository, all you really need is to do is push your changes to Github, Bitbucket, Gitlab or whatever code repository you use. I chose Gitlab because private repos are free and I didn’t want to have my drafts public, even in repo-form.

After that’s that set up, you need to find a host. What you really want is a host that offers continuous integration and deployment so that merging to your master branch triggers the npm run build command and regenerates your site.

I used Gitlab’s own CI tools for the first few months after I set this up. I found the setup to be easy but troubleshooting issues to be difficult. I recently switched to Netlify, which has an outstanding free plan and some great CLI tools built right in.

In both cases, you’re able to point your own domain at their servers and even setup an SSL certificate for HTTPS support—that last point being important if you ever want to experiment with things like the getUserMedia API, or create a shop to make sales.

With all this set up, you’re now a member of the Butt-less Website club. Congratulations and welcome, friends! Hopefully you find this to be a simple alternative to complex content management systems for your own personal website and that it allows you to experiment with ease. Please let me know in the comments if you get stuck along the way…or if you succeed beyond your wildest dreams. 😉