How To Use Folders And Filenames As Paths For Your Webpages With Gridsome

April 25, 2021 · 9 min read

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:

  • set up Gridsome to use your folder structure for website paths
  • show a tree/taxonomy page of all your pages

In a hurry? You'll find the full code at the end of the article.

How To Use Folders As URL Paths

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).

How To Display A Nested Site Tree (Taxonomy Page) In Gridsome

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 this blog (links to /about/)
  • physics

    • What is thermodynamics? (links to /physics/laws-of-thermodynamics/)

      • The first law of thermodynamics (links to /physics/laws-of-thermodynamics/first/)
      • The second law of thermodynamics (links to /physics/laws-of-thermodynamics/second/)
      • The third law of thermodynamics (links to /physics/laws-of-thermodynamics/third/)
  • writing

    • How to write great post titles (links to /writing/post-titles/)
    • What to write about? (links to /writing/what-to-write-about/)
    • How to select images? (links to /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>

Selecting An Approach

There are two ways we can approach displaying a nested list from a flat input:

  1. keep the list mostly unchanged and put most of the logic into the Vue template itself
  2. prepare the list so the Vue template can stay as dumb as possible

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:

  • For a path consisting of multiple components like /writing/post-titles/, we want a category called writing to be created
  • Paths can be nested arbitrarily deep
  • Articles and categories have indentation according to their path (a top-level blog post has indentation 0, while an article with the path /physics/laws-of-thermodynamics/first/ has indentation 2)
  • Sorted alphabetically within a category and on the same level

Creating The Data Structure To Display Our List

We create the data structure in four steps, which we'll go over in detail:

  1. Get the raw posts form GraphQL
  2. Add an array with the path components (let's call that property splitPath)
  3. Sort the posts correctly
  4. Insert category headers

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)
    }
  }
}

Editing The Vue Template To Display The List

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>

Putting All Pieces Together

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>

I hope this helps!

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.