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.