Blogging with the Vuepress Default Theme

Published 07/18/2018, 12:00:00 AM GMT-4
Categorized: JavaScript Tagged: VuePress

In this article I'll go over why I chose VuePress to power my website and blog, the features of VuePress I took advantages of, Vue components I made to add a basic tags and category system to the default Vuepress theme, and how I'm managing writing posts until there is an official blogging theme. This isn't so much a guide as an overview of what I'm doing with VuePress, but there will be code just in case you want to take a look.


Table of Contents


Why VuePress?

My personal domain has gone pretty much unused for some time; I've had both WordPress and Tumblr behind it in the past, but I never really wrote enough or felt I needed a website to put in any real effort to blogging. Recently I've been trying to be more organized and I've realized that writing things down really helps!

I have been really into Vue lately too, and Single Page Applications (SPAs), so when VuePress was announced I knew I wanted to spend some time looking into it. Other static site generators are a bit more feature rich in terms of content management, but I'm sticking with VuePress, at least for now, because it provides easy access to Vue, and gives me a snappy site curtousy of Vue Router and Webpack.


The Components

RenderlessPagesList Component

This component will do the heavy lifting for the rest of the components. I learned about renderless Vue components from Adam Wathan's "Advanced Vue Component Design" and wanted to apply some of what I learned (or remembered) while also playing around VuePress.

I pass the $site.pages that VuePress provides to the RenderlessPagesList component and it handles filtering and returning pages based on categories, tags, and/or paths; the rest of the components wrap RenderlessPagesList and provide the markup for the pages we get back.

// .vuepress/components/RenderlessPagesList.vue

<script>
import {
  compact,
  flatMap,
  uniq,
  each,
  get,
  filter,
  some,
  includes,
  sortBy
} from "lodash"
import { format, toDate } from "date-fns"
export default {
  props: {
    byTags: {
      default() {
        return []
      },
      type: [Array, String]
    },
    byCategories: {
      default() {
        return []
      },
      type: [Array, String]
    },
    byPaths: {
      default() {
        return []
      },
      type: [Array, String]
    },
    notTags: {
      default() {
        return []
      },
      type: [Array, String]
    },
    notCategories: {
      default() {
        return []
      },
      type: [Array, String]
    },
    notPaths: {
      default() {
        return []
      },
      type: [Array, String]
    }
  },
  computed: {
    filteredPages() {
      this.filterPages()
      return this.pages
    }
  },
  data() {
    return {
      pages: []
    }
  },
  mounted() {
  },
  methods: {
    filterPages() {
      this.setPages()
      this.filterOutByPaths()
      this.filterByPaths()
      this.filterByCategories()
      this.filterByTags()
      this.filterOutByCategories()
      this.filterOutByTags()
      this.sortByMostRecent()
    },
    filterByTags() {
      this.filterIncludes("byTags", "frontmatter.tags")
    },
    filterByCategories() {
      this.filterIncludes("byCategories", "frontmatter.categories")
    },
    filterByPaths() {
      this.filterStartsWith("byPaths", "path")
    },
    filterOutByTags() {
      this.filterIncludes("notTags", "frontmatter.tags", true)
    },
    filterOutByCategories() {
      this.filterIncludes("notCategories", "frontmatter.categories", true)
    },
    filterOutByPaths() {
      this.filterIsNot("notPaths", "path")
    },
    setPages() {
      this.pages = this.$site.pages
    },
    filterIncludes(byWhat, byKey, exclude = false) {
      if (!get(this, byWhat).length) return

      let self = this

      this.pages = filter(this.pages, function(page) {
        let yesNo = some(get(page, byKey), pageKeyValue =>
          includes(get(self, byWhat), pageKeyValue)
        )
        return !exclude ? yesNo : !yesNo
      })
    },
    filterStartsWith(byWhat, byKey) {
      if (!get(this, byWhat).length) return

      let self = this

      this.pages = filter(this.pages, function(page) {
        let pageKeyValues = get(self, byWhat)
        if (typeof pageKeyValues === "string") {
          pageKeyValues = [pageKeyValues]
        }
        let yesNos = []
        each(pageKeyValues, value =>
          yesNos.push(get(page, byKey).startsWith(value))
        )
        return some(yesNos)
      })
    },
    filterIsNot(byWhat, byKey) {
      if (!get(this, byWhat).length) return

      let self = this

      this.pages = filter(this.pages, function(page) {
        let pageKeyValues = get(self, byWhat)
        if (typeof pageKeyValues === "string") {
          pageKeyValues = [pageKeyValues]
        }
        let yesNos = []
        each(pageKeyValues, value => yesNos.push(get(page, byKey) === value))
        return !some(yesNos)
      })
    },
    sortByMostRecent() {
      this.pages = sortBy(this.pages, [(page) => { return format(toDate(page.frontmatter.date), 'S'); }]).reverse()
    },
    categories() {
      return compact(uniq(flatMap(this.pages, "frontmatter.categories"))).sort()
    },
    tags() {
      return compact(uniq(flatMap(this.pages, "frontmatter.tags"))).sort()
    },
    formatAnchor(string) {
      return string.toLowerCase().replace(/ /g, "-")
    },
    formatDate(date) {
      return format(toDate(date), 'P ZZ')
    }
  },
  render() {
    return this.$scopedSlots.default({
      pages: this.filteredPages,
      tags: this.tags,
      categories: this.categories,
      formatDate: this.formatDate,
      formatAnchor: this.formatAnchor
    })
  }
}
</script>

Keeping Blog components DRY

All three blog components later in the article will use this file. It acts as a configuration file for setting the default props sent to the RenderlessPagesList component and allows us to reuse the props.

// .vuepress/components/blogPageListProps.js

export default {
  byTags: {
    default() {
      return []
    },
    type: [Array, String]
  },
  byCategories: {
    default() {
      return []
    },
    type: [Array, String]
  },
  byPaths: {
    default() {
      return ["/blog"]
    },
    type: [Array, String]
  },
  notTags: {
    default() {
      return []
    },
    type: [Array, String]
  },
  notCategories: {
    default() {
      return []
    },
    type: [Array, String]
  },
  notPaths: {
    default() {
      return ["/blog/tags/", "/blog/", "/blog/categories/"]
    },
    type: [Array, String]
  }
}

BlogPosts Component

I use this component within the README.md for the /blog/ page.

// .vuepress/components/BlogPosts.vue

<template>
  <RenderlessPagesList :byPaths="byPaths" :notPaths="notPaths" :byTags="byTags" :byCategories="byCategories" :notTags="notTags" :notCategories="notCategories">
    <div slot-scope="{ pages, formatDate }">
      <div v-for="page in pages" :key="page.path">
        <small class="text-grey">
          {{ formatDate(page.frontmatter.date) }} &bull;
        </small>
        <a :href="page.path">{{ page.title }}</a><br/>
      </div>
    </div>
  </RenderlessPagesList>
</template>

<script>
import RenderlessPagesList from "./RenderlessPagesList"
import blogPageListProps from "./blogPageListProps"
export default {
  props: blogPageListProps,
  components: {
    RenderlessPagesList
  }
}
</script>

Blog Posts by Date Page

// /blog/README.md

---

title: Blog Posts by Date

---

# {{ $page.title }}

<BlogPosts />

BlogPostsByCategory Component

I use this component within the README.md for the /blog/categories/ page.

// .vuepress/components/BlogPostsByCategory.vue

<template>
  <RenderlessPagesList :byPaths="byPaths" :notPaths="notPaths" :byTags="byTags" :byCategories="byCategories" :notTags="notTags" :notCategories="notCategories">
    <div slot-scope="{ pages, categories, formatDate, formatAnchor }">
      <div v-for="category in categories()" :key="category">
        <h2>
          <a :href="'#'+formatAnchor(category)" aria-hidden="true" class="header-anchor">#</a>
          {{ category }}
        </h2>
        <BlogPosts :byCategories="category" />
      </div>
    </div>
  </RenderlessPagesList>
</template>

<script>
import RenderlessPagesList from "./RenderlessPagesList"
import blogPageListProps from "./blogPageListProps"
export default {
  props: blogPageListProps,
  components: {
    RenderlessPagesList
  }
}
</script>

Blog Posts by Category Page

// /blog/categories/README.md

---

title: Blog Posts by Category

---

# {{ $page.title }}

<BlogPostsByCategory />

BlogPostsByTag Component

I use this component within the README.md for the /blog/tags/ page.

// .vuepress/components/BlogPostsByTag.vue

<template>
  <RenderlessPagesList :byPaths="byPaths" :notPaths="notPaths" :byTags="byTags" :byCategories="byCategories" :notTags="notTags" :notCategories="notCategories">
    <div slot-scope="{ pages, tags, formatDate, formatAnchor }">
      <div v-for="tag in tags()" :key="tag">
        <h2>
          <a :href="'#'+formatAnchor(tag)" aria-hidden="true" class="header-anchor">#</a>
          {{ tag }}
        </h2>
        <BlogPosts :byTags="tag" />
      </div>
    </div>
  </RenderlessPagesList>
</template>

<script>
import RenderlessPagesList from "./RenderlessPagesList"
import blogPageListProps from "./blogPageListProps.js"

export default {
  props: blogPageListProps,
  components: {
    RenderlessPagesList
  }
}
</script>

Blog Posts by Tag Page

// /blog/categories/README.md

---

title: Blog Posts by Tag

---

# {{ $page.title }}

<BlogPostsByTag />

BlogPostMeta Component

I use this component to add post meta data to blog posts.

// /.vuepress/components/BlogPostMeta.vue

<template>
  <small>
    <div v-if="$page.frontmatter.date">Published {{ formatDate($page.frontmatter.date) }}</div>
    <span v-if="$page.frontmatter.categories">
      Categorized:
      <a v-for="(category, index) in $page.frontmatter.categories" :key="index" :href="'/blog/categories/#'+formatAnchor(category)">
        {{category}}
      </a>
    </span>
    <span v-if="$page.frontmatter.tags">
      Tagged:
      <a v-for="(tag, index) in $page.frontmatter.tags" :key="index" :href="'/blog/tags/#'+formatAnchor(tag)">
        {{tag}}
      </a>
    </span>
  </small>
</template>

<script>
import { toDate, format, formatRelative } from "date-fns"
export default {
  methods: {
    toDate,
    format,
    formatRelative,
    formatDate(date) {
      return formatRelative(toDate(date), new Date())
    },
    formatAnchor(string) {
      return string.toLowerCase().replace(/ /g, "-")
    }
  }
}
</script>

I wanted my blog paths to only show up on blog pages so we've done that with the following VuePress sidebar config.

// .vuepress/config.js

let blogSideBarPaths = ["/blog/", "/blog/categories/", "/blog/tags/"]

module.exports = {
  // ...
  themeConfig: {
    nav: [{ text: "Blog", link: "/blog/" }],
    sidebar: {
      "/blog/": blogSideBarPaths,
      "/blog/tags/": blogSideBarPaths,
      "/blog/categories/": blogSideBarPaths,
      // fallback
      "/": []
    }
  }
}

Final Folder Structure

When we're all done our folder structure will look something like this.

Root Folder
├─ .unpublished // a hidden folder for unpublished pages/articles
│  └─ an-unbulished-post-or-page
│     └─ README.md
├─ .vuepress
│  ├─ components
│     ├─ blogPageListProps.js
│     ├─ BlogPostMeta.vue
│     ├─ BlogPosts.vue
│     ├─ BlogPostsByCategory.vue
│     ├─ BlogPostsByTag.vue
│     └─ RenderlessPagesList.vue
│  └─ config.js
├─ blog
│  ├─ example-blog-post
│     ├─ .snippets // a hidden folder for files to be imported
│        ├─ file-to-import-into-readme.json
│        └─ file-to-import-into-readme.js
│     └─ README.md // An example blog post
│  ├─ categories
│     └─ README.md // Blog Posts by Category
│  ├─ tags
│     └─ README.md // Blog Posts by Tag
│  └─ README.md // Blog Posts by Date
├─ README.md // Home page
└─ package.json

Writing Blog Posts

An Example Blog Post

## // /blog/an-example-blog-post/README.md

---

title: An Example Blog Post
date: 2018-16-07 00:00:00 -0500
sidebar: auto
categories: [Blog]
tags: [Writing]

---

# An Example Blog Post

<BlogPostMeta/>

Blog post intro

**Table of Contents**

[[toc]]

## Sections listed in Sidebar

Section content

<<< @/blog/an-example-blog-post/.snippets/file-to-import-into-readme.js

### Sub sections listed in Sidebar

Sub Section content

<<< @/blog/an-example-blog-post/.snippets/file-to-import-into-readme.md

Managing Unpublished Posts

At the moment I don't think VuePress has any way to mark pages or posts as unpublished. I have created an .unpublished hidden folder to store any article drafts I'm working on.


Keeping Blog Posts DRY

While writing this post I realized that code snippets really increase the size of your Markdown files. Luckily, VuePress 0.10.1+ allows you to Import Code Snippets.

// An example of importing this exact code snippet

<<< @/blog/blogging-vuepress-default-theme/.snippets/import-example.md

I've opted to store my snippets in a hidden .snippets folder so that .md examples aren't processed into html files by vuepress dev or vuepress build


Get an example on GitHub!

Example VuePress Blog on Github