[Part 2] Write and apply a custom Vuepress theme

hyper_yolo

Amie Chen

Posted on February 12, 2019

[Part 2] Write and apply a custom Vuepress theme

hero-image

In this part, we are going to write some components for your theme with vue.js.

0. Setup some dummy blog posts

Let's add a few sample markdown files first. I have made some for you to download. Unzip it and put the blog folder under the root. Like the README.md you create in the last article, Vuepress will use the README.md as the default index.html of your blog folder. We will use it to display a list of blog posts.

directory

1. Create the theme's layouts

There are 3 layouts in the theme we are going to create:

  • page (e.g. Homepage)
  • List of posts (e.g. Blog index page)
  • post detail (e.g. A Blog post)

If you worked with wordpress/jekyll before, you're probably familiar with the idea of page v.s post. A page is a static page without published date, it usually contains timeless content like the homepage; whereas a post is a timely blogpost.

In the layouts folder, in addition to the Layout.vue we created in the previous article, create 2 more files: PostsLayout.vue, PostLayout.vue.

layout directory

Now let's give each of them some minimum template. Add this to PostsLayout.vue

<template>
  <div>
    <h1>list of posts</h1>
    <Content/>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

and add similar thing to PostLayout.vue

<template>
  <div>
    <h1>post detail</h1>
    <Content/>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

<Content /> is a Vuepress component that loads slot-free content from your markdown file. Anything that's not wrapped in-between ::: slot-key would be loaded.

In case you are not aware, it's important to know that you can only have 1 child under <template> for Vue.js to work correctly. If you have multiple children directly <template> you will get an error like Error compiling template .... Component template should contain exactly one root element.

Also, notice that in each markdown file in /blog, I've already indicated in the frontmatter that which layouts to use

---
layout: PostLayout
---
Enter fullscreen mode Exit fullscreen mode

Now if you head to http://localhost:8080/blog/post-1.html you should see a page like below... which means you've binded each layout to the post correctly! Yay!
post detail

2. Create shared components

Now we are ready to add shared components like the global nav and footer. In the theme folder, create a components folder with 2 files inside: Nav.vue and Footer.vue. Notice it's a vue convention that components files are capitalized.

Nav

Simple thing first: let's add some template to the Nav.vue file.

<template>
  <header>
    <nav
      class="font-sans bg-white text-center flex justify-between my-4 mx-auto container overflow-hidden"
    >
      <a
        href="/"
        class="block text-left no-underline font-bold text-black uppercase"
      >{{$site.title}}</a>
      <ul class="text-sm text-grey-dark list-reset flex items-center">
        <li>
          <a
            class="inline-block py-2 px-3 text-grey-darkest hover:text-grey-dark no-underline"
          >menu</a>
        </li>
      </ul>
    </nav>
  </header>
</template>
Enter fullscreen mode Exit fullscreen mode

As you can see, our Nav consists of a \$site title and some menu items. To programatically generate menu items, we first need to add a themeConfig object to our config.js. This is where we are going to site menu data.

module.exports = {
  title: "Vuepress Blog Example",
  description: "just another blog",
  themeConfig: {
    nav: [{ text: "Blog", link: "/blog/" }, { text: "About", link: "/" }],
  },
  postcss: {
    plugins: [
      require("tailwindcss")("./tailwind.config.js"),
      require("autoprefixer"),
    ],
  },
}
Enter fullscreen mode Exit fullscreen mode

Now themeConfig will be available under the global computed $site. Did you notice we have already used it to render the \$site.title?

To programatically generate each menu item, we can utilize v-for to access the $site.themeConfig.nav that we just added to the config.js. Also, the link of each would be available to us. We can add to each item with :href.

<li v-for="item in $site.themeConfig.nav">
  <a
    :href="item.link"
    class="inline-block py-2 px-3 text-grey-darkest hover:text-grey-dark no-underline"
  >{{item.text}}</a>
</li>
Enter fullscreen mode Exit fullscreen mode

v-for and :href are both Vue directives, where :href is a shorthand of v-bind:href. The first one simply means: for every item in $site.themeConfig.nav object, render this <li> block; and the later one is binding item.link to a vue rendered href. You could also use the plain old href but then you'd not be able to access to what's inside of item.

Now your Nav.vue should look like this:

<template>
  <header>
    <nav
      class="font-sans bg-white text-center flex justify-between my-4 mx-auto container overflow-hidden"
    >
      <a
        href="/"
        class="block text-left no-underline font-bold text-black uppercase"
      >{{$site.title}}</a>
      <ul class="text-sm text-grey-dark list-reset flex items-center">
        <li v-for="item in $site.themeConfig.nav">
          <a
            :href="item.link"
            class="inline-block py-2 px-3 text-grey-darkest hover:text-grey-dark no-underline"
          >{{item.text}}</a>
        </li>
      </ul>
    </nav>
  </header>
</template>

Enter fullscreen mode Exit fullscreen mode

Vuepress utilizes vue-router's router-link, which is preferred than a hard-coded <a>. When in HTML5's history mode, router-link won't refresh the page on clicking links and it also progressively supports IE 9's hash-mode. For all these good reasons, we are going to replace all the <a> to <router-link>, and all href to be :to.

<template>
  <header>
    <nav
      class="font-sans bg-white text-center flex justify-between my-4 mx-auto container overflow-hidden"
    >
      <router-link
        :to="'/'"
        class="block text-left no-underline font-bold text-black uppercase"
      >{{$site.title}}</router-link>
      <ul class="text-sm text-grey-dark list-reset flex items-center">
        <li v-for="item in $site.themeConfig.nav">
          <router-link
            :to="item.link"
            class="inline-block py-2 px-3 text-grey-darkest hover:text-grey-dark no-underline"
          >{{item.text}}</router-link>
        </li>
      </ul>
    </nav>
  </header>
</template>
Enter fullscreen mode Exit fullscreen mode

Footer

Not much magic is going on in the footer. Just add this template to Footer.vue

<template>
  <footer class="font-sans bg-black text-white py-8 px-4">
    <div class="text-grey-darker text-center">©2019 Yours truely. All rights reserved.</div>
  </footer>
</template>
Enter fullscreen mode Exit fullscreen mode

Putting everything together

Because we want every single page to have the Nav and the Footer, we have to tell our layouts where to find them. Update the all layout files with the following:

<template>
  <div class="flex flex-col h-full">
    <Nav/>
    <Content class="flex-1 max-w-xl mx-auto leading-normal"/>
    <Footer class="pin-b"/>
  </div>
</template>

<script>
import Nav from "@theme/components/Nav";
import Footer from "@theme/components/Footer";
export default {
  components: { Nav, Footer },
  name: "Layout"
};
</script>

<style lang="stylus">
@import '../styles/theme.styl';
</style>
Enter fullscreen mode Exit fullscreen mode

Since tailwind is not the main focuse of this tutorial, I already included some tailwind classes in the templates to make everything looked decent. Update the theme.styl with these css, under @tailwind components;

body, html, #app {
  height: 100%; /* makes the footer sticked to bottom */
}
Enter fullscreen mode Exit fullscreen mode

By now, you should have pages look like this. Both menu items (Blog and About) are generated from our config!

localhost:8080
homepage

localhost:8080/blog/
homepage

localhost:8080/blog/post-1.html
homepage

Starting to look better, right?

3. List of posts page

To show a list of blog posts, we can create a globally computed properties posts. Update the export of PostsLayout.vue with the following:

export default {
  components: { Nav, Footer },
  name: "Layout",
  computed: {
    posts() {
      return this.$site.pages
        .filter(x => x.path.startsWith("/blog/"))
        .sort(
          (a, b) => new Date(b.frontmatter.date) - new Date(a.frontmatter.date)
        )
    },
  },
}
Enter fullscreen mode Exit fullscreen mode

computed are values that will be computed when Vue starts and it will update itself when the data changes. Which means, you don't need to do extra work to get the new value ... How great is that! Inside we are saving the value of computed to the name posts.$site.pages is one of the Vuepress globals that gives you all the pages in the site, including the non-blog ones. To get a list of posts, I only want the pages under /blog. Therefore in the code above I filtered out the pages I don't need then sort the result by date before returning the value.

Now we can utilize the computed properties posts in our template. Replace <Content /> with this snippet

<ul class="flex-1 max-w-xl mx-auto leading-normal">
  <li v-for="post in posts">
    <router-link :to="post.path">{{ post.title }}</router-link>
  </li>
</ul>
Enter fullscreen mode Exit fullscreen mode

Also, add the missing date into each blog post's frontmatter. Simply fake some date for the purpose of this tutorial.

date: 2019-02-11
Enter fullscreen mode Exit fullscreen mode

If you go to localhost:8080/blog/ now, you should see our list of posts showing!
list of posts

Wait, how come there's an empty list item on top? Right, because we forgot to filter out the README.md in /blog, which is not a blog post.

Let's add some logic to filter it out:

computed: {
  posts() {
    return this.$site.pages
      .filter(x => x.path.startsWith("/blog/") && !x.frontmatter.blog_index)
      .sort(
        (a, b) => new Date(b.frontmatter.date) - new Date(a.frontmatter.date)
      );
  }
}
Enter fullscreen mode Exit fullscreen mode

In README.md(the one under the blog folder), add blog_index: true to the frontmatter

---
layout: PostsLayout
blog_index: true
---
Enter fullscreen mode Exit fullscreen mode

Now if you check localhost:8080/blog/ again, the empty list item should be gone. Try click on every post link and see if it shows the correct post!

4. Use Vuepress plugins

I've always find reading-time info on Medium.com very useful, so let's add similar functionality. Luckily there's already a vuepress plugin exists so we don't have to write our own.

npm install -D vuepress-plugin-reading-time
Enter fullscreen mode Exit fullscreen mode

Add plugins: ['vuepress-plugin-reading-time'] into your config.js.

Replace the <Content/> in your PostLayout.vue with this:

<article class="flex-1 mx-auto leading-normal container">
  <label class="text-grey-dark">{{$page.readingTime.text}}</label>
  <content />
</article>
Enter fullscreen mode Exit fullscreen mode

Voila! Refresh any of your blog detail page, you should see the grey reading time on top:
reading-time image

As you can see, adding/using plugins in Vuepress is super easy and powerful. There aren't many plugins available yet so it's likely that you have to write your own.

Work with external node packages

Usually there's a published date on each post and we can easily accompolish that by adding a date in the frontmatter. But the output still has to be parsed it to be a human readable format, which Vuepress doesn't have support for it yet.

We can use extra tool like moment.js to help:

npm install moment
Enter fullscreen mode Exit fullscreen mode

In PostLayout.vue, add the template to render your date, it'd render something like 2019-02-13T00:00:00.000Z.

<label class="text-grey-dark">{{$page.frontmatter.date}}</label>
Enter fullscreen mode Exit fullscreen mode

To fix this, let's load moment package to our layout. Update the <script> with these:

import moment from "moment"
import Nav from "@theme/components/Nav"
import Footer from "@theme/components/Footer"
export default {
  components: { Nav, Footer },
  name: "Layout",
  methods: {
    formateDate(date) {
      return moment(date).format("MMM Do YYYY")
    },
  },
}
Enter fullscreen mode Exit fullscreen mode

Then apply it onto the template

<label class="text-grey-dark">{{formateDate($page.frontmatter.date)}}</label>
Enter fullscreen mode Exit fullscreen mode

Now you should see a human-readable date format like Feb 13th 2019!

5. Wrapping up

Phew You did it! This is a long tutorial I know, but if you followed through, you'd learn many concepts including

  • How to create a custom theme for Vuepress
  • How to use basic Vue directives in your theme
  • How to work with external node modules
  • How to use Vuepress plugins

Next, we are going to learn how to deploy it on Netlify. It's a short simple one, I promise! Let's go, Part 3


This is a cross-post from my website. Check out the original and more there!

💖 💪 🙅 🚩
hyper_yolo
Amie Chen

Posted on February 12, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related