Creating blog with VuePress

I want to migrate from Blogger to static files. In this post I am investigating VuePress static site generator.

Setup

Add package.json.

Install vuepress

npm install -D vuepress

Add scripts to package.json

"scripts": {
  "dev": "vuepress dev .",
  "build": "vuepress build ."
},

Create /.vuepress/config.js

module.exports = {
  title: "QwertoBlog", // Title on left side of navbar
  description: "Qwertovsky blog",
  head: [
    ['link', { rel: 'icon', href: '/qwertoblog_favicon.svg' }],
    ['meta', { content: 'Valery Qwertovsky', name: 'author'}],
    ['meta', {content: 'Qwertovsky, Blog, Qwertoblog, Квертовский', name: 'keywords'}]
  ],
  themeConfig: {
    search: false, //remove search field from navbar
    navbar: true,
    nav: [
      { text: 'Site', link: 'https://qwertovsky.com' },
    ]
  }
}

Add qwertoblog_favicon.svg to /.vuepress/public/

Add /README.md. It will be /index.html.

Show all posts

In README.md I added list of posts

---
{
  "index": true
}
---
<PostsList />

Add component PostsList.vue to /.vuepress/components/. This component will show list of posts.

<template>
<div>
  <div v-for="post in posts" class="post">
    <div class="post_title">
      <a :href="postUrl(post)" target="_blank" rel="noreferrer">
        {{ post.frontmatter.title }}
      </a>
    </div>

    <div class="post_meta">
      {{ post.frontmatter.date }}
    </div>

    <div class="post_content" v-html="postDescription(post)">
    </div>
  </div>
</div>
</template>

<script>
export default {
  computed: {
    posts() {
      const posts = this.$site.pages
        .filter(post => !post.frontmatter.index)
        .sort((a, b) => new Date(b.frontmatter.date) - new Date(a.frontmatter.date));
      return posts;
    }
  },
  methods: {
    postUrl(post) {
      if (post.frontmatter.url) {
        return post.frontmatter.url;
      } else {
        return post.path;
      }
    },
    postDescription(post) {
      if (post.excerpt) {
        return post.excerpt;
      } else {
        return post.frontmatter.description;
      }
    }
  }
}
</script>

Add to post markdown file attributes

---
{
  "title": "Page title",
  "description": "Page description",
  "date": "2019-12-31",
  "index": false,
  "url": null
}
---

I use "index": true to mark pages with list of posts. I filter this pages out.
Attribute "url": "https://example.com/external_post.html" shows that post was placed on external site.

If description should be formatted, a text attribute is not useful. I can add the commented <!-- more --> and take description from post.

---
{
  "description": null
}
---
Beggining part of post. This will be available as `$page.excerpt`.
<!-- more -->
More text.

Pagination

I can use $route.query, get page number and show part of my posts. And it will work with vuepress dev. But why if static pages can't do that?

I decided to create all pages by hand. There will folders page1, page2, ... And every folder will contain README.md. Root folder too.

.
├── README.md
├── page1
|   ├── README.md
|   ├── post1.md
|   |   ...
|   └── post10.md
├── page2
|   ├── README.md
|   ├── post11.md
|   |   ...
|   └── post20.md
|   ...
└── page10
    ├── README.md
    ├── post91.md
    └── post92.md

Root README.md is copy from last page10 folder. So my main page will show only 2 last posts. It looks unusual. But all the previous pages will display the same topics at any time. And that is good for search engines. Because there will not broken links from search.

In README.md I changed index attribute to page number. And I used the other layout for an index pages. Attribute last indicates last index page.

---
{
  "index": 10,
  "layout": "ListLayout",
  "last": true
}
---
<PostsList />

And in PostsList I changed filter

computed: {
  pageNumber() {
    let index = this.$frontmatter.index;
    return index;
  },
  posts() {
    const posts = this.$site.pages
      .filter(post => !post.frontmatter.index
        && post.path.startsWith("/page" + this.pageNumber + "/"))
      .sort((a, b) => new Date(b.frontmatter.date) - new Date(a.frontmatter.date));
    return posts;
  }
},

/.vuepress/theme/layouts/ListLayout.vue

<template>
  <div>
    <Content />
    <div class="pagination">
      <div class="pagination_older">
        <a v-if="prevUrl" :href="prevUrl" title="Old">« Old</a>
      </div>
      <div class="pagination_newer">
        <a v-if="nextUrl" :href="nextUrl" title="New">New »</a>
      </div>
    </div>
  </div>
</template>
<script>
export default {
  name: "ListLayout",
  computed: {
    pageNumber() {
      let index = this.$frontmatter.index;
      return index;
    },
    prevUrl() {
      if (this.pageNumber > 1) {
        const prevIndex = this.pageNumber - 1;
        return this.$withBase("/page" + prevIndex);
      }
      return null;
    },
    nextUrl() {
      if (this.$frontmatter.last) {
        return null;
      }
      const nextIndex = this.pageNumber + 1;
      return this.$withBase("/page" + nextIndex);
    }
  },
}
</script>

Default theme

New ListLayout.vue will broke default theme. To fix that I inherit default theme /.vuepress/theme/index.js

module.exports = {
  extend: '@vuepress/theme-default'
}

/.vuepress/theme/layouts/ListLayout.vue

<template>
  <div>
    <DefaultLayout>
    </DefaultLayout>
    <div class="pagination">
      <div class="pagination_older">
        <a v-if="prevUrl" :href="prevUrl" title="Old">« Old</a>
      </div>
      <div class="pagination_newer">
        <a v-if="nextUrl" :href="nextUrl" title="New">New »</a>
      </div>
    </div>
  </div>
</template>

<script>
import DefaultLayout from "@parent-theme/layouts/Layout.vue";
export default {
  name: "ListLayout",
  components: {
    DefaultLayout
  },
  // ...
</script>

May be I should do

  <DefaultLayout>
    <template #post-bottom>
      <div class="pagination">
        <div class="pagination_older">
          <a v-if="prevUrl" :href="prevUrl" title="Old">« Old</a>
        </div>
        <div class="pagination_newer">
          <a v-if="nextUrl" :href="nextUrl" title="New">New »</a>
        </div>
      </div>
    </template>
  </DefaultLayout>

But this code with slot does not work.

Override default style is possible with /.vuepress/theme/styles/index.styl and /.vuepress/theme/styles/palette.styl. But I faced a issue. My styles sometimes was been placed before default style. So my styles did not override the default one. I found a relative issue 1885. I made my rules more specific and forgot about this problem.

Plugin @vuepress/blog

Install dependency

npm install -D @vuepress/plugin-blog

Add plugin to config.js

  plugins: [
    ['@vuepress/blog',
      {
        directories: [
          {
            // Unique ID of current classification
            id: 'post',
            // directory with markdown files for posts
            dirname: 'posts',
            // directory with result index.html and page/
            path: '/post/',
            // path to post
            itemPermalink: '/post/:slug.html',
            // layout for index page
            layout: 'Index',
            itemLayout: 'Post',
            pagination: {
              lengthPerPage: 20,
              // layout for index page when page != 1
              layout: 'Index'
            }
          },
        ],
        frontmatters: [
          {
            // Unique ID of current classification
            id: 'tags',
            // Decide that the frontmatter keys will be grouped under this classification
            keys: ['tags'],
            // Path of the `entry page` (or `list page`)
            path: '/tag/',
            // Layout of the `entry page` (list of tags)
            layout: 'Tags',
            // Layout of the `scope page` (lsit of posts with this tag)
            scopeLayout: 'Index'
          },
        ],
      },
    ],
  ],

Move all posts' md-files to directory posts. It means dirname: 'posts' in config. Result subdirectory of vuepress result will be path: '/post/'. itemPermalink should be started with path value.

And there is you can face problem 32 as I did. Don't name dirname and path with same name.

Next you need create layouts: for posts index (Index), for post (Post), for tags index (Tags). Index layout will work for posts that was filtered by tag. Layout.vue will work for other pages.

GlobalLayout.vue

<template>
  <div>
    <header>
      <router-link to="/">{{ $site.title }}</router-link>
      <router-link to="/tag/">Tags</router-link>
      <router-link to="/post/">Posts</router-link>
    </header><br>
    <DefaultGlobalLayout/>
    <footer>
    </footer>
  </div>
</template>
<script>
  import GlobalLayout from '@app/components/GlobalLayout.vue'
  export default {
    components: { DefaultGlobalLayout: GlobalLayout },
  }
</script>

Layout.vue

<template>
  <Content/>
</template>

Index.vue

<template>
  <div>
    <ul>
      <li v-for="page of $pagination.pages">
        <router-link :to="page.path">{{ page.title }}</router-link>
      </li>
    </ul>
    <div id="pagination">
      <router-link v-if="$pagination.hasPrev" :to="$pagination.prevLink">Prev</router-link>
      <router-link v-if="$pagination.hasNext" :to="$pagination.nextLink">Next</router-link>
    </div>
  </div>
</template>

Post.vue

<template>
  <Content/>
</template>

Tags.vue

<template>
  <ul>
    <li v-for="tag in $tags.list">
      <router-link :to="tag.path">{{ tag.name }}</router-link>
    </li>
  </ul>
</template>

Big advantage of plugin is tags and automatic pagination.
Disadvantage is the pagination has reversed numeration. The first oldest post will on last page. And if new posts come, all pages will have new number. Search engines will have to reindexing all pages.

Back to manual pagination

npm uninstall -D @vuepress/plugin-blog

I can create pages for posts by hand. But it is not so easy for tags. So I decided to create plugin and use additionalPages method for tags and for pagination.

config.js

plugins: [
  [require('./plugin-qwertoblog.js'), {
    postsDir: "posts",
    postsPerPage: 2
  }]
],

plugin-qwertoblog.js

module.exports = (options, context) => {
  const POSTS_DIR = options.postsDir;
  const POSTS_PER_PAGE = options.postsPerPage;

  createPagesForPosts = (posts, indexPage) => {
    const pages = [];
    const base = indexPage.path;
    const tag = indexPage.frontmatter.tag;
    const maxIndex = Math.ceil(posts.length / POSTS_PER_PAGE);
    indexPage.frontmatter.index = maxIndex;
    if (maxIndex > 1) {
      indexPage.frontmatter.prevUrl = base + "page/" + (maxIndex - 1) + "/";
    }
    for (let i = 1; i < maxIndex; i++) {
      let nextUrl = base;
      if (i < maxIndex - 1) {
        nextUrl = base + "page/" + (i + 1) + "/";
      }
      let prevUrl = null;
      if (i > 1) {
        prevUrl = base + "page/" + (i - 1) + "/";
      }
      pages.push({
        path: base + "page/" + i + "/",
        title: `${indexPage.title} | Page ${i}`,
        frontmatter: {
          index: i,
          layout: "Index",
          tag: tag,
          base: base,
          prevUrl,
          nextUrl
        }
      });
    }
    return pages;
  }

  getAllPosts = (tag) => {
    return context.pages.filter(post => 
      !post.frontmatter.index
        && post.path.startsWith(`/${POSTS_DIR}/`)
        && (tag == null || post.frontmatter.tags && post.frontmatter.tags.indexOf(tag) >= 0)
    );
  }

  getAllTags = () => {
    let tags = getAllPosts()
    .map((p) => {
      return p.frontmatter.tags || [];
    }).flat();
    tags = [...new Set(tags)];
    tags = tags.sort((a, b) => a.localeCompare(b));
    tags = tags.map((t) => {
      return {
        name: t,
        path: context.base + "tag/" + t + "/"
      };
    });
    return tags;
  }

  return {
    name: "vuepress-plugin-qwertoblog",
    clientDynamicModules() {
      return {
        name: 'options.js',
        content: `
        export const options = ${JSON.stringify(options)};
        `
      }
    },
    additionalPages() {
      const pages = [];

      const posts = getAllPosts();

      const tags = getAllTags();
      pages.push({
        path: context.base + "tag/",
        frontmatter: {
          title: "Tags",
          layout: "Tags",
          index: 1
        }
      });

      // create main index pages for tags
      const tagIndexes = tags.map((t) => {
        return {
          path: t.path,
          title: `Tag '${t.name}'`,
          frontmatter: {
            index: 1,
            last: true,
            layout: 'Index',
            tag: t.name,
            base: t.path
          }
        }
      });
      pages.push(...tagIndexes);

      // create pages for each tags
      for (let t = 0; t < tags.length; t++) {
        const tagPosts = posts.filter((p) => 
          p.frontmatter.tags && p.frontmatter.tags.indexOf(tags[t].name) >= 0
        );
        pages.push(...createPagesForPosts(tagPosts, tagIndexes[t]));
      }

      // create main page for all posts
      const postsIndex = {
        path: context.base,
        title: "Posts",
        frontmatter: {
          index: 1,
          last: true,
          layout: 'Index',
          base: context.base
        }
      }
      pages.push(postsIndex);
      // create pages for posts
      pages.push(...createPagesForPosts(posts, postsIndex));

      return pages;
    }
  }
}

Index.vue

<template>
  <div>
    <ul>
      <li v-for="page of posts">
        <router-link :to="page.path">{{ page.title }}</router-link>
      </li>
    </ul>
    <div id="pagination">
      <router-link v-if="prevLink" :to="prevLink">Prev</router-link>
      <router-link v-if="nextLink" :to="nextLink">Next</router-link>
    </div>
  </div>
</template>
<script>
import { options } from "@dynamic/options.js";
export default {
  computed: {
    posts() {
      return this.getPagePosts(this.$frontmatter.tag, this.$frontmatter.index);
    },
    preLink() {
      return this.$frontmatter.prevUrl;
    },
    nextLink() {
      return this.$frontmatter.nextUrl;
    }
  },
  methods: {
    getPagePosts(tag, index) {
      let posts = this.$site.pages
      .filter(post => 
        !post.frontmatter.index
          && post.path.startsWith(`/${options.postsDir}/`)
          && (tag == null || post.frontmatter.tags && post.frontmatter.tags.indexOf(tag) >= 0)
      )
      .sort((a, b) => new Date(b.frontmatter.date) - new Date(a.frontmatter.date));
      posts = posts.slice(Math.max(0, posts.length - index * options.postsPerPage),
        posts.length - (index - 1) * options.postsPerPage);
      return posts;
    },
  }
}
</script>

Tags.vue

<template>
  <ul id="default-layout" >
    <li v-for="tag in tags">
      <router-link class="page-link" :to="tag.path">{{ tag.name }}</router-link>
    </li>
  </ul>
</template>
<script>
import { options } from "@dynamic/options.js";
export default {
  computed: {
    tags() {
      let tags = this.$site.pages
      .filter((post) => {
        !post.frontmatter.index
        && post.path.startsWith(`/${options.postsDir}/`)
      })
      .map((p) => {
        return p.frontmatter.tags || [];
      })
      .flat();
      tags = [...new Set(tags)];
      tags = tags.sort((a, b) => a.localeCompare(b));
      tags = tags.map((t) => {
        return {
          name: t,
          path: t
        };
      })
      return tags;
    }
  }
}
</script>

As you see I have the dublicated code in layout and in plugin. I tried to move the code to another js-file and share between plugin and layout. I wrote export, require, import but faced errors

SyntaxError: Unexpected token 'import'
SyntaxError: Unexpected token 'export'
Cannot assign to read only property 'exports' of object
SyntaxError: Cannot use import statement outside a module

And I gave up.
Interesting that methods getAllPosts and getAllTags available in layouts for vuepress build but with vuepress dev methods are not defined.

There is another problem. I changed base to /blog/ in config.js. Command vuepress build did it as I expected. Almost. The posts html files were placed to /, not to /blog/. Only my additional pages and tags were placed to /blog/.
vuepress dev did it all wrong and served root index.html as http://loalhost:8080/blog/blog/. All links were broken except links to posts (they are in root).
To fix that I returned back base to /. And added base to my plugin options.

plugins: [
  [require('./plugin-qwertoblog.js'), {
    postsDir: "blog/posts",
    postsPerPage: 2,
    base: "/blog/"
  }]
],

In the plugin I don't use context.base. I change page path with extendPageData. prevUrl and nextUrl are releative now.

module.exports = (options, context) => {
  const POSTS_DIR = options.postsDir;
  const POSTS_PER_PAGE = options.postsPerPage;
  const BASE = options.base;

  withCtxBase = (path) => {
    if (path.charAt(0) === '/') {
      return BASE + path.slice(1);
    } else {
      return path;
    }
  }

  createPagesForPosts = (posts, indexPage) => {
    const pages = [];
    const base = indexPage.path;
    const tag = indexPage.frontmatter.tag;
    const maxIndex = Math.ceil(posts.length / POSTS_PER_PAGE);
    indexPage.frontmatter.index = maxIndex;
    if (maxIndex > 1) {
      indexPage.frontmatter.prevUrl = "page/" + (maxIndex - 1) + "/";
    }
    for (let i = 1; i < maxIndex; i++) {
      let nextUrl = "../../";
      if (i < maxIndex - 1) {
        nextUrl = "../" + (i + 1) + "/";
      }
      let prevUrl = null;
      if (i > 1) {
        prevUrl = "../" + (i - 1) + "/";
      }
      pages.push({
        path: base + "page/" + i + "/",
        title: `${indexPage.title} | Page ${i}`,
        frontmatter: {
          index: i,
          layout: "Index",
          tag: tag,
          prevUrl,
          nextUrl
        }
      });
    }
    return pages;
  }

  getAllPosts = (tag) => {
    return context.pages.filter(post => 
      !post.frontmatter.index
        && post.path.startsWith(`/${POSTS_DIR}/`)
        && (tag == null || post.frontmatter.tags && post.frontmatter.tags.indexOf(tag) >= 0)
    );
  }

  getAllTags = () => {
    let tags = getAllPosts()
    .map((p) => {
      return p.frontmatter.tags || [];
    }).flat();
    tags = [...new Set(tags)];
    tags = tags.sort((a, b) => a.localeCompare(b));
    tags = tags.map((t) => {
      return {
        name: t,
        path: "/tag/" + t + "/"
      };
    });
    return tags;
  }

  return {
    name: "vuepress-plugin-qwertoblog",
    define() {
      return {
        OPTIONS: options
      }
    },
    extendPageData ($page) {
      $page.path = withCtxBase($page.path);
    },
    additionalPages() {
      const pages = [];

      const posts = getAllPosts();
      const tags = getAllTags();

      // index for tags
      pages.push({
        path: "/tag/",
        frontmatter: {
          title: "Tags",
          layout: "Tags",
          index: 1
        }
      });

      // create main index pages for tags
      const tagIndexes = tags.map((t) => {
        return {
          path: t.path,
          title: `Tag '${t.name}'`,
          frontmatter: {
            index: 1,
            last: true,
            layout: 'Index',
            tag: t.name
          }
        }
      });
      pages.push(...tagIndexes);

      // create pages for each tags
      for (let t = 0; t < tags.length; t++) {
        const tagPosts = posts.filter((p) => 
          p.frontmatter.tags && p.frontmatter.tags.indexOf(tags[t].name) >= 0
        );
        pages.push(...createPagesForPosts(tagPosts, tagIndexes[t]));
      }

      // create main page for all posts
      const postsIndex = {
        path: "/",
        title: "Posts",
        frontmatter: {
          index: 1,
          last: true,
          layout: 'Index'
        }
      }
      pages.push(postsIndex);
      // create pages for posts
      pages.push(...createPagesForPosts(posts, postsIndex));

      return pages;
    }
  }
}

Garbage in the html files

VuePress generates html files. All this files have tags

<link rel="preload" href="/assets/js/app.b25dff79.js" as="script">

There is 17 chunks. What is it? I want static pages. Why do I get scripts on them?

Now I should clean all files one by one. It is not good.

Conclusion

Vuepress is good for documentation. Default theme is very nice.
But customization may be painful. To make something complicated I should use enhanceAppFiles. @vuepress/plugin-blog is example. On my opinion it is not convenient to split scripts for server-side and for client-side. But there is something in Vuepress that I don't understand.
I will try another generator.