This is part twenty of a series on building a file-based blog from scratch with Gridsome. Find the full series here.
The standard way for URLs in Gridsome is the slug
parameter.
But you can also tie your folder structure to your website structure and use directories and filenames as paths, like other frameworks such as Next.js do.
At the end of this article, you'll know how to:
In a hurry? You'll find the full code at the end of the article.
Using the folder structure for paths is actually the default in the Vue Remark plugin for Gridsome.
So to use it, first set up your Gridsome project to use Vue Remark.
Now remove the route
option from the Vue Remark config in grisome.config.js
:
module.exports = {
// ...
plugins: [
{
use: '@gridsome/vue-remark',
options: {
typeName: 'BlogPost',
baseDir: './content/blog',
// route: '/:slug', // remove this line
template: './src/templates/BlogPost.vue',
includePaths: ['./content/markdown-snippets'],
plugins: [
['gridsome-plugin-remark-shiki', { theme: 'nord', skipInline: true }]
],
refs: {
tags: {
typeName: 'Tag',
create: true
}
}
}
},
// ...
Then you can remove the slug
info from everywhere else in your project (your blog post frontmatter and probably other configs like your forestry config).
Let's get to an interesting problem.
We can get all blog posts from Gridsome together with their paths with the allBlogPost
query:
<page-query>
query Posts {
posts: allBlogPost (sortBy: "date", order: DESC) {
edges {
node {
title
date (format: "MMMM D, Y")
path
}
}
}
}
</page-query>
This gives us a flat list of blog posts.
A filename like /writing/post-titles.md
(within your content folder) will give an allBlogPost
result of:
{
edges: [
{
node: {
title: 'How to write great post titles',
date: 'April 3, 2021',
path: '/writing/post-titles/'
}
},
...
]
}
Note that the paths end with a slash and without the .md
extension.
Now, how do we get from a flat list of paths and titles like this…:
/writing/post-titles/
: How to write great post titles/physics/laws-of-thermodynamics/
: What is thermodynamics?/writing/what-to-write-about/
: What to write about?/writing/how-to-select-images/
: How to select images?/about/
: About this blog/physics/laws-of-thermodynamics/first/
: The first law of thermodynamics/physics/laws-of-thermodynamics/second/
: The second law of thermodynamics/physics/laws-of-thermodynamics/third/
: The third law of thermodynamics… to a nested structure like this:
/about/
)physics
What is thermodynamics? (links to /physics/laws-of-thermodynamics/
)
/physics/laws-of-thermodynamics/first/
)/physics/laws-of-thermodynamics/second/
)/physics/laws-of-thermodynamics/third/
)writing
/writing/post-titles/
)/writing/what-to-write-about/
)/writing/how-to-select-images/
)For this to work we need to rewrite our blog posts overview page.
Start by making sure the page query returns all blog posts with their title, date, and path:
<page-query>
query Posts {
posts: allBlogPost (sortBy: "date", order: DESC) {
edges {
node {
title
date (format: "MMMM D, Y")
path
}
}
}
}
</page-query>
There are two ways we can approach displaying a nested list from a flat input:
I personally like keeping complex logic out of the templates, so let's pick option 2. We just need to find a data format the template can display without much transformation.
Such a data format can look like this:
[
{
title: 'writing',
path: null,
date: null,
indentation: 0
},
{
title: 'How to write great post titles',
date: 'April 3, 2021',
path: '/writing/post-titles/',
indentation: 1
}
]
As you see, we want to insert categories without links into the list where necessary, and also add the indentation.
Our list should have the following properties:
/writing/post-titles/
, we want a category called writing
to be created/physics/laws-of-thermodynamics/first/
has indentation 2)We create the data structure in four steps, which we'll go over in detail:
splitPath
)In Vue, what we want to create is a computed property, so let's add one that creates an array with our posts to the src/pages/Blog.vue
:
<script>
export default {
metaInfo: {
title: 'Blog Posts'
},
computed: {
articlesTree () {
const posts = this.$page.posts.edges.map(edge => edge.node)}
},
// ...
}
</script>
From now on we'll just look at the articlesTree
function.
Let's create an array with split up path components:
articlesTree () {
const posts = this.$page.posts.edges.map(edge => edge.node)
// slice because paths start and end with '/'
const postsWithPaths = posts.map(post => ({ ...post, splitPath: post.path.split('/').slice(1, -1) }))
}
Next, we want to sort this array in-place with the native sort function.
But how exactly should we sort?
Like we would expect a directory tree to be sorted. On every level, items are sorted alphabetically:
articlesTree () {
const posts = this.$page.posts.edges.map(edge => edge.node)
// slice because paths start and end with '/'
const postsWithPaths = posts.map(post => ({ ...post, splitPath: post.path.split('/').slice(1, -1) }))
postsWithPaths.sort((postA, postB) => {
const lenA = postA.splitPath.length
const lenB = postB.splitPath.length
// it only makes sense to compare on every level
// if one path has more components than another, only compare as long as both paths have levels
for (let i = 0; i < Math.min(lenA, lenB); i++) {
if (postA.splitPath[i] !== postB.splitPath[i]) {
// if, e.g., the 3rd component of the paths are not equal, do a string comparison
return postA.splitPath[i] < postB.splitPath[i] ? -1 : 1
}
}
// if we didn't exit until here, the we have e.g. this situation:
// pathA = '/posts/categoryA/article1', pathB = '/posts/categoryA'
// so if both have the same length, return that they are equal
if (lenA === lenB) return 0
// and if one is longer than the other, the shorter path should come first
return lenA < lenB ? -1 : 1
})
}
Finally we create our result array, which includes the category labels and adds correct indentation info to every list item:
articlesTree () {
// ... first part OMITTED for readability
const result = []
postsWithPaths.forEach((post, i) => {
const lastPost = i > 0 ? postsWithPaths[i - 1] : null
if (!lastPost && post.splitPath.length > 1) {
// special handler for if the first post already has multiple path components
// we need a forEach here, because the path may be even deeper than one level,
// and we want one category label on every level
post.splitPath.slice(0, -1)
.forEach((path, j) => result.push({ title: path, indentation: post.splitPath.length - j - 2 }))
}
if (lastPost && this.getDifferingPaths(post.splitPath, lastPost.splitPath).length > 0) {
this.getDifferingPaths(post.splitPath, lastPost.splitPath)
.forEach((path, j) => result.push({ title: path, indentation: this.firstDifferingIndex(post.splitPath, lastPost.splitPath) + j }))
}
result.push({ ...post, indentation: post.path.split('/').length - 3 })
})
return result
}
You will notice that I used two component methods, one to find the first array index where the path is different from the last one, and another to get the parts of a path that are different to the last path. Here is the definition of those methods:
export default {
// ...
methods: {
firstDifferingIndex (currentPath, lastPath) {
// we want to show everything from the first different path component
// if currentPath = ['test', 'path', 'file1']
// and lastPath = ['test', 'for', 'file2']
// the forst different path component is at index 1
return currentPath.findIndex((p, idx) => p !== lastPath[idx])
},
getDifferingPaths (currentPath, lastPath) {
const firstDifferingIdx = this.firstDifferingIndex(currentPath, lastPath)
// now return the paths from the first differing path, excluding the last part (the filename)
return currentPath.slice(firstDifferingIdx, -1)
}
}
}
All we have left to do now is editing the Vue template itself. We want to render the flat list of items with the correct indentation:
<template>
<Layout>
<h1 class="text-2xl text-gray-900 dark:text-gray-100 font-semibold mb-5">
Blog Posts
</h1>
<ul class="list-outside list-disc">
<template
v-for="(post, i) in articlesTree"
>
<li
:key="i"
class="mt-3"
:style="`margin-left: ${(post.indentation - 1) * 16}px`"
>
<g-link
v-if="post.path"
:to="post.path"
class="underline"
>
{{ post.title }} – {{ post.date }}
</g-link>
<div
v-if="!post.path"
class="font-bold"
>
{{ post.title }}
</div>
</li>
</template>
</ul>
</Layout>
</template>
The src/pages/Blog.vue
file now looks like this:
<template>
<Layout>
<h1 class="text-2xl text-gray-900 dark:text-gray-100 font-semibold mb-5">
Blog Posts
</h1>
<ul class="list-outside list-disc">
<template
v-for="(post, i) in articlesTree"
>
<li
:key="i"
class="mt-3"
:style="`margin-left: ${(post.indentation - 1) * 16}px`"
>
<g-link
v-if="post.path"
:to="post.path"
class="underline"
>
{{ post.title }} – {{ post.date }}
</g-link>
<div
v-if="!post.path"
class="font-bold"
>
{{ post.title }}
</div>
</li>
</template>
</ul>
</Layout>
</template>
<page-query>
query Posts {
posts: allBlogPost (sortBy: "date", order: DESC) {
edges {
node {
title
date (format: "MMMM D, Y")
path
}
}
}
}
</page-query>
<script>
export default {
metaInfo: {
title: 'Blog Posts'
},
computed: {
articlesTree () {
const posts = this.$page.posts.edges.map(edge => edge.node)
// slice because paths start and end with '/'
const postsWithPaths = posts.map(post => ({ ...post, splitPath: post.path.split('/').slice(1, -1) }))
postsWithPaths.sort((postA, postB) => {
const lenA = postA.splitPath.length
const lenB = postB.splitPath.length
for (let i = 0; i < Math.min(lenA, lenB); i++) {
if (postA.splitPath[i] !== postB.splitPath[i]) {
return postA.splitPath[i] < postB.splitPath[i] ? -1 : 1
}
}
if (lenA === lenB) return 0
return lenA < lenB ? -1 : 1
})
const result = []
postsWithPaths.forEach((post, i) => {
const lastPost = i > 0 ? postsWithPaths[i - 1] : null
if (!lastPost && post.splitPath.length > 1) {
post.splitPath.slice(0, -1)
.forEach((path, j) => result.push({ title: path, indentation: post.splitPath.length - j - 2 }))
}
if (lastPost && this.getDifferingPaths(post.splitPath, lastPost.splitPath).length > 0) {
this.getDifferingPaths(post.splitPath, lastPost.splitPath)
.forEach((path, j) => result.push({ title: path, indentation: this.firstDifferingIndex(post.splitPath, lastPost.splitPath) + j }))
}
result.push({ ...post, indentation: post.path.split('/').length - 3 })
})
return result
}
},
methods: {
firstDifferingIndex (currentPath, lastPath) {
return currentPath.findIndex((p, idx) => p !== lastPath[idx])
},
getDifferingPaths (currentPath, lastPath) {
const firstDifferingIdx = this.firstDifferingIndex(currentPath, lastPath)
return currentPath.slice(firstDifferingIdx, -1)
}
}
}
</script>
That's it for this addition to my Gridsome series.
If you have more questions about Gridsome, check out the full Gridsome tutorial or shoot me an email at simon@mannes.tech.
If you want to support my work, check out my project Nerdful Mind, where I write about mindfulness and mindful productivity for software developers.