Blogging with the Vuepress Default Theme
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) }} •
</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>
Navigation & Sidebar
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