Creating blog with Vue.js

Earlier I tried to build blog with VuePress (link). And I did not like it. In this post I am trying to create static files for blog with Vue.js and prerender-spa-plugin.

Setup

cd blog
npm install -g @vue/cli
vue create .
npm install -D cache-loader
npm install -D prerender-spa-plugin
mkdir posts
./.envVUE_APP_BASE=/
VUE_APP_POSTS_DIR=./posts/
./vue.config.jsconst POSTS_DIR = process.env.VUE_APP_POSTS_DIR;
module.exports = {
  configureWebpack: {
    resolve: {
      alias: {
        'PostsDir': path.resolve(__dirname, POSTS_DIR)
      }
    }
  }
}

Directory ./posts is for blog articles. There are my markdown and html files.
How can vue.js application get information from these files? I found frontmatter-markdown-loader. This webpack loader transforms markdown file to object with frontmatter atributes and a vue component. So I can import attributes and a component from md file and use them in my vue components.

<script>
  import fm from "post.md";
  export default {
    components: {
      PostComponent: fm.vue.component
    },
    computed: {
      fm() {
        return fm.attributes;
      }
    }
  }
</script>

For html files I can use html-loader.

<template>
  <div>
    <component :is="html" />
  </div>
</template>
<script>
  import post from "post.html";
  export default {
    data() {
      return {
        content: html
      }
    }
  }
</script>

But html will not contain attributes. And frontmatter-markdown-loader does not return excerpt from post. With excerpt I can use the first part of a post that is before tag <!-- more --> as description for my blog index page. So I need a custom loader.

Loader

I use gray-matter to get frontmatter attributes, excerpt, and full content. Then I apply markdown-it to content and excerpt from markdown files. I skip this step for html files. I should escape html for code blocks that come from markdown. I assume html files are valid.
I wrap html with one tag, because this html is used as a template for vue component.
File name is used for path in router. So in the loader I add new attribute slug. Slug is like id for post. It is unique as well as file name is.

npm install -D gray-matter
npm install -D markdown-it
npm install -D escape-html
npm install -D vue-template-compiler
./post_loader.jsconst grayMatter = require("gray-matter");
const markdownIt = require("markdown-it");
const escapeHtml = require("escape-html");

function wrap(code, lang = "text") {
  code = escapeHtml(code);
  return `<pre v-pre class="language-${lang}"><code>${code}</code></pre>`
}

function oneRootElement(html) {
  return `<div>${html}</div>`;
}

const markdown = markdownIt({
  html: true,
  highlight: wrap
});

module.exports = function(source) {
  const fmData = grayMatter(source, { excerpt_separator: "<!-- more -->"});

  const fileName = this.resourcePath.substring(this.resourcePath.lastIndexOf("/") + 1);
  const slug = fileName.substring(0, fileName.lastIndexOf("."));
  fmData.data.slug = slug;

  if (fileName.endsWith(".md")) {
    fmData.content = markdown.render(fmData.content);
    if (fmData.excerpt) {
      fmData.excerpt = markdown.render(fmData.excerpt);
    }
  }
  if (!fmData.excerpt) {
    fmData.excerpt = fmData.data.description;
  }

  fmData.content = oneRootElement(fmData.content);
  fmData.excerpt = oneRootElement(fmData.excerpt);

  return `export default ${ JSON.stringify(fmData) }`;
};
./vue.config.jsmodule.exports = {
  configureWebpack: {
    // ...
    module: {
      rules: [
        {
          test: new RegExp(POSTS_DIR + ".+\.(md|html)$"),
          use: [
            {
              loader: path.resolve(__dirname, "post_loader.js"),
            }
          ]
        }
      ]
  }
}

I use loader only for posts folder. "\.(md|html)$" will break main template public/index.html.

Show post

Now I can import all posts and create the blog index page. To do so I need to create file index.js in the posts dir and export all files one by one.

./posts/index.jsimport FirstPost from "./00001_first_post.html";
//...
import VertxTestPost from "./00021_vertx_test.md";
import VuePressPost from "./00022_creating_blog_with_vuepress.md";
import BlogVueJsPost from "./00023_creating_blog_with_vue.js.md";

const posts = [
  FirstPost,
  //...
  VertxTestPost,
  VuePressPost,
  BlogVueJsPost
];
export default posts;

So my post is object like this:

{
  "data": { // frontmater attributes
    "title": "Crating blog with Vue.js",
    "date": "2020-01-20",
    "tags": ["vue.js", "frontend"],
    "slug": "00023_creating_blog_with_vue.js"
  },
  "excerpt": "<div>...</div>", // html content above <!-- more -->
  "content": "<div>...</div>" // full post
}

For pagination I added new property to environment.

./.envVUE_APP_POSTS_PER_PAGE=20

Component Index.vue is used for main index and for index on each tag page.

./src/router/index.jsimport Vue from 'vue'
import VueRouter from 'vue-router'

import Index from '@/components/Index.vue'
import Tags from '@/components/Tags.vue'
import Post from '@/components/Post.vue'

import About from "@/views/About.vue";

import posts from "PostsDir";

Vue.use(VueRouter)

const routes = [
  {
    path: "/about",
    component: About
  },
  {
    path: '/',
    component: Index
  },
  {
    path: '/page/:index',
    component: Index
  },
  {
    path: '/tag',
    component: Tags,
    meta: {
      title: "Tags"
    }
  },
  {
    path: '/tag/:tag',
    component: Index
  },
  {
    path: '/tag/:tag/page/:index',
    component: Index
  },
  {
    path: '/posts/:slug',
    name: "post",
    component: Post,
  }
];

const router = new VueRouter({
  mode: "history",
  routes
});
router.beforeEach((to, from, next) => {
  let title = "QwertoBlog";
  if (to.meta.title) {
    title = to.meta.title + " | " + title;
  } else if (to.params["slug"]) {
    const slug = to.params["slug"];
    const post = posts.find((p) => p.data.slug == slug)
    title = post.data.title + " | " + title;
  } else if (to.params["index"]) {
    const index = to.params["index"];
    title = "Page " + index + " | " + title;
  }
  if (to.params["tag"]) {
    const tag = to.params["tag"];
    title = "Tag " + tag + " | " + title;
  }
  document.title = title;
  next();
});

export default router
./src/components/Index.vue<template>
  <div>
    <div v-for="post in posts" :key="post.data.slug">
      <Post :post-data="post" :more="false" />
      <hr />
    </div>
    <div class="pagination">
      <div class="pagination_older">
        <router-link v-if="hasPrev" :to="prevUrl" title="Old">« Old</router-link>
      </div>
      <div class="pagination_home">
      </div>
      <div class="pagination_newer">
        <router-link v-if="hasNext" :to="nextUrl" title="New">New »</router-link>
      </div>
    </div>
    
  </div>
</template>

<script>
import allPosts from "PostsDir";
import Post from "@/components/Post.vue";

const POSTS_PER_PAGE = process.env.VUE_APP_POSTS_PER_PAGE;

export default {
  computed: {
    hasPrev() {
      return !(this.maxIndex == 1 || this.index == 1)
    },
    hasNext() {
      return !!this.index && this.index != this.maxIndex;
    },
    prevUrl() {
      let url = null;
      
      if (this.maxIndex == 1 || this.index == 1) {
        return null;
      }
      if (this.tag) {
        url = `/tag/${this.tag}/page/${this.index - 1}`;
      } else {
        url = `/page/${this.index - 1}`;
      }
      return url;
    },
    nextUrl() {
      let url = null;
      if (this.index == this.maxIndex) {
        return null;
      }
      if (this.index == this.maxIndex - 1) {
        if (this.tag) {
          url = `/tag/${this.tag}`;
        } else {
          url = `/`;
        } 
      } else {
        if (this.tag) {
          url = `/tag/${this.tag}/page/${this.index + 1}`;
        } else {
          url = `/page/${this.index + 1}`;
        }
      }
      return url;
    },
    postsByTag() {
      let posts = allPosts
      .filter(post => 
        (this.tag == null || post.data.tags && post.data.tags.indexOf(this.tag) >= 0)
      )
      .sort((a, b) => new Date(b.data.date) - new Date(a.data.date));
      return posts;
    },
    tag() {
      return this.$route.params["tag"];
    },
    index() {
      let index = Number(this.$route.params["index"]);
      if (!index) {
        index = this.maxIndex;
      }
      return index;
    },
    maxIndex() {
      return Math.ceil(this.postsByTag.length / POSTS_PER_PAGE);
    }, 
    posts() {
      let posts = this.postsByTag;
      let index = this.index;
      if (!index) {
        index = this.maxIndex;
      }
      posts = posts.slice(Math.max(0, posts.length - index * POSTS_PER_PAGE),
        posts.length - (index - 1) * POSTS_PER_PAGE);
      return posts;
    }
  },
  components: {
    Post
  }
}
</script>
./src/components/Post.vue<template>
  <div class="post">
    <div class="post_titile">
      <a v-if="outbound" :href="postUrl" target="_blank">
        {{ title }}
        <OutLink />
      </a>
      <router-link v-else-if="postUrl" :to="postUrl">{{ title }}</router-link>
      <span v-else>{{title}}</span>
    </div>
    <div class="post_meta">
      {{fm.date}}
      <MetaTags :tags="fm.tags" />
    </div>
    <div class="post_content">
      <component :is="content"></component>
    </div>
  </div>
</template>

<script>
import posts from "PostsDir";
import MetaTags from "@/components/MetaTags.vue";
import OutLink from "@/components/OutLink.vue";
export default {
  components: {
    MetaTags,
    OutLink
  },
  props: {
    postData: Object,
    more: {
      // true - show all post, false - only excerpt
      type: Boolean,
      default: true
    }
  },
  data() {
    let post = this.postData;
    if (!post) {
      // postData exists on index page, it is null if post is opened by direct link
      const slug = this.$route.params["slug"];
      post = posts.find((p) => p.data.slug == slug);
    }
    return {
      post
    }
  },
  computed: {
    title() {
      return this.fm && this.fm.title;
    },
    fm() {
      return this.post && this.post.data;
    },
    content() {
      return {
        template: this.more ? this.post.content : this.post.excerpt
      };
    },
    postUrl() {
      let postUrl = "/posts/" + this.fm.slug;
      const slug = this.$route.params["slug"];
      if (slug) {
        postUrl = null;
      } else if (this.fm.url) {
        postUrl = this.fm.url;
      }
      return postUrl;
    },
    outbound() {
      return this.fm.url;
    },
  }
}
</script>

You can see that I use a dynamic component to show post content. And this component is just html template. Runtime compilation must be enabled for it. Otherwise we get error:

[Vue warn]: You are using the runtime-only build of Vue where the template compiler is not available. Either pre-compile the templates into render functions, or use the compiler-included build.

So I need to turn it on in vue.js

./vue.config.jsmodule.exports = {
  runtimeCompiler: true,
  // ...
}

Tags

The component to show tags on post.

./src/components/MetaTags.vue<template>
  <span>
    <span v-for="t of tags" :key="t">
      | <router-link :to="tagUrl(t)">{{t}}</router-link>
    </span>
  </span>
</template>

<script>
export default {
  props: {
    tags: Array
  },
  methods: {
    tagUrl(tag) {
      return "/tag/" + tag;
    }
  }
}
</script>

The component to show tags list.

./src/components/Tags.vue<template>
  <ul id="default-layout" >
    <li v-for="tag in tags" :key="tag.name">
      <router-link class="page-link" :to="tag.path">{{ tag.name }}</router-link>
    </li>
  </ul>
</template>

<script>
import posts from "PostsDir";
export default {
  computed: {
    tags() {
      let tags = posts
        .map((p) => p.data.tags || [])
        .reduce((tagsP, tagsC) => tagsP.concat(tagsC))
        .sort((a, b) => a.localeCompare(b));
      tags = [...new Set(tags)]
        .map((t) => {
          return {
            name: t,
            path: "/tag/" + t
          };
        });
      return tags;
    }
  }
}
</script>

Prerender SPA plugin

Now I can build static files from my SPA. To do so I should tell plugin all routes that I want to have static. For blog it means all routes.

./vue.config.jsconst path = require("path");
const PrerenderSPAPlugin = require("prerender-spa-plugin");
const fs = require("fs");
const grayMatter = require("gray-matter");

const POSTS_DIR = process.env.VUE_APP_POSTS_DIR;
const POSTS_PER_PAGE = process.env.VUE_APP_POSTS_PER_PAGE;
const BLOG_BASE = process.env.VUE_APP_BASE;

function createPagesForPosts (posts, base) {
  const pages = [];
  const maxIndex = Math.ceil(posts.length / POSTS_PER_PAGE);
  for (let i = 1; i < maxIndex; i++) {
    pages.push(base + "page/" + i);
  }
  return pages;
}

const allPosts = [];
const routes = [];
let tags = [];

routes.push("/");

fs.readdirSync(POSTS_DIR, {withFileTypes: true})
  .filter((file) => {
    return file.isFile() && /^.+\.(md|html)$/.test(file.name);
  })
  .forEach((file) => {
    const fmData = grayMatter.read(POSTS_DIR + file.name);
    allPosts.push(fmData.data);
    if (!fmData.data.url) {
      const fileName = file.name;
      const slug = fileName.substring(0, fileName.lastIndexOf("."));
      routes.push("/posts/" + slug);
    }
    if (fmData.data.tags) {
      tags.push(fmData.data.tags);
    }
  });
routes.push(...createPagesForPosts(allPosts, "/"));

tags = tags.flat();
tags = [...new Set(tags)];
tags = tags.map((t) => {
  return {
    name: t,
    path: "/tag/" + t
  };
});
routes.push("/tag");
tags.forEach((t) => {
  routes.push(t.path);
  const tagPosts = allPosts.filter((p) => p.tags && p.tags.indexOf(t.name) >= 0);
  routes.push(...createPagesForPosts(tagPosts, t.path + "/"));
});

module.exports = {
  // ...
  configureWebpack: config => {
    const blogConfig = {
    // ...
    };
    if (process.env.NODE_ENV === 'production') {
      const prerenderPlugin = new PrerenderSPAPlugin({
        staticDir: path.join(__dirname, "dist"),
        routes,
      });
      blogConfig.plugins = [prerenderPlugin];
    }
    return blogConfig;
  },
}

Now I can run a build and all pages will be in ./dist folder.

Prerender plugin waits when all scripts and styles will be loaded. And 500ms in addition. When script for vue.js app are loaded this code will be executed synchronously. Browser shows that page is been loading. And the prerender waits too. And window.onload event will be trigered when all vue components are mounted.

I can brake this behaviour if I add async router guard.

beforeEnter: async (to, from, next) => {
  next();
},

The page will be loaded at first and then vue components will be mounted. So I can't use prerender with async code without additional configuration. The plugin has options: to wait custom event on document, or wait any time.

Remove scripts from build

I wrote about VuePress that scripts remain on a page after build. Now I have this problem again.
Why is it a problem? As I said before, a window loading is complete when all components are mounted. When all content exists on page I don't need to start the SPA processing. More time is the problem.

I will remove scripts on load event.

./main.js// ...
new Vue({
  router,
  render: function (h) { return h(App) }
}).$mount('#app');

if (process.env.NODE_ENV === 'production') {
  window.onload = function() {
    document.getElementById("spaScripts").remove();
  };
}
./src/public/index.html<head>
  <!-- ... -->
  <% for (key in htmlWebpackPlugin.files.css) { %>
    <link href="<%= htmlWebpackPlugin.files.css[key] %>" rel="stylesheet" />
  <% } %>
<head>
<body>
  <!-- ... -->
  <!-- built files will not be auto injected -->
  <div id="spaScripts">
    <!-- injecting explicitly -->
    <% for (var chunk in htmlWebpackPlugin.files.chunks) { %>
      <script src="<%= htmlWebpackPlugin.files.chunks[chunk].entry %>"></script>
      <% } %>
  </div>
</body>
./vue.config.js// ...
module.exports = {
  // ...
  chainWebpack: config => {
    config
      .plugin('html')
      .tap(args => {
        const options = args[0];
        options.minify = false;
        options.inject = false;
        return args;
      })
  },
  // ...
}

I edited HtmlWebpackPlugin. I turned off the auto-injecting scripts to the template body. I defined the placement in my template for created scripts. And I can remove just this scripts and leave others. I found Default template in html-webpack-plugin docs. You can keep other blocks.
With disabled minification it is easier to check result.

Condition process.env.NODE_ENV === 'production' will work for blog when all pages must be static. Otherwise I should check another flag.

./vue.config.jsconst Renderer = PrerenderSPAPlugin.PuppeteerRenderer;
//...
const prerenderPlugin = new PrerenderSPAPlugin({
  //...
  renderer: new Renderer({
    injectProperty: '__PRERENDER_INJECTED',
    inject: {
      prerendered: true
    }
  })
});
//...

Renderer will inject property to window on processed pages.

./main.js// ...
if (window.__PRERENDER_INJECTED && window.__PRERENDER_INJECTED.prerendered) {
  window.onload = function() {
    document.getElementById("spaScripts").remove();
  };
}

Change base path

I can place files to https://blog.example.com/ and links will work. What if I want to serve blog from https://example.com/blog/?
I can change the router base and change routes for the plugin.

./.envVUE_APP_BASE=/blog/
./vue.config.js// ...
const BLOG_BASE = process.env.VUE_APP_BASE;
// ...
module.exports = {
  // ...
  configureWebpack: {
    // ...
    plugins: [
      new PrerenderSPAPlugin({
        staticDir: path.join(__dirname, "dist"),
        routes: routes.map(r => (BLOG_BASE + r).replace("//", "/"))
      })
    ]
  },
  publicPath: BLOG_BASE
}
./src/router.index.js// ...
const router = new VueRouter({
  mode: "history",
  base: process.env.BASE_URL, // == publicPath from vue.config.js
  routes
});
// ...

But after a build the pages will be empty. Because a request come to /blog/index.html but the plugin serve index.html from /. There is the problem (https://github.com/chrisvfritz/prerender-spa-plugin/issues/344).

To solve that I added server property to the plugin.

./vue.config.js// ...
new PrerenderSPAPlugin({
  staticDir: path.join(__dirname, "dist"),
  routes: routes.map(r => (BLOG_BASE + r).replace("//", "/")),
  server: {
    port: 3000,
    proxy: {
      "/blog/": {
        target: "http://localhost:3000",
        pathRewrite: {
          "^/blog/": "/"
        }
      }
    }
  }
})
// ...

The base is hardcoded. I can rewrite it:

./vue.config.jsconst BLOG_BASE = process.env.VUE_APP_BASE;
const PRERENDER_SERVER = {
  port: 3000,
  proxy: {}
};
PRERENDER_SERVER.proxy[BLOG_BASE] = {
  target: `http://localhost:${PRERENDER_SERVER.port}`,
  pathRewrite: {}
};
PRERENDER_SERVER.proxy[BLOG_BASE].pathRewrite[`^${BLOG_BASE}`] = "/";
// ...
new PrerenderSPAPlugin({
  staticDir: path.join(__dirname, "dist"),
  routes: routes.map(r => (BLOG_BASE + r).replace("//", "/")),
  server: PRERENDER_SERVER
})
// ...

Now ./dist folder contains BLOG_BASE folder with posts. But all other assets and public files are in ./dist. I want to move them under /dist/blog.

./vue.config.jsconst DIST_DIR = "dist";
//...
  const prerenderPlugin = new PrerenderSPAPlugin({
    staticDir: path.join(__dirname, DIST_DIR, BLOG_BASE),
    outputDir: path.join(__dirname, DIST_DIR),
    //...
  });
//...
outputDir: path.join(__dirname, DIST_DIR, BLOG_BASE)

Images

I may include the image in my post

<img src="PostsDir/00008/window.png" />

But this does not work.

I can move the image to ./src/assets

<img src="@/assets/window.png" />

This does not work too.

Vue Loader does this processing when compiles a component. Loader transforms a url to webpack requests. Then file-loader moves images and adds hashes. New url will be placed to img:src.
My template does not go through Vue Loader. Hence urls stay as-is.

I can solve this easy. I will move all images under /public and be done with it. The image /public/window.png will be available as src="/window.png".
But I don't take easy road. I want keep images under PostsDir next to posts' files. To do so I should change my loader and compile templates to components.

./post_loader.jsconst vueTemplateCompiler = require('vue-template-compiler');
const compilerUtils = require('@vue/component-compiler-utils');
const compileTemplate = compilerUtils.compileTemplate;

function compile(componentName, html) {
  const compileOptions = {
    source: html,
    compiler: vueTemplateCompiler,
    compilerOptions: {
      outputSourceRange: true
    },
    transformAssetUrls: true,
    isProduction: process.env.NODE_ENV === 'production'
  };
  
  const contentCode = compileTemplate(compileOptions).code;
  return `
  let ${componentName}Component;
  {
    const extractVueFunctions = () => {
      ${contentCode}
      return { render, staticRenderFns };
    }
    const vueFunctions = extractVueFunctions();
    ${componentName}Component = {
      data: function () {
        return {
          templateRender: null
        }
      },
      render: function (createElement) {
        return this.templateRender ? this.templateRender(createElement) : createElement("div", "Rendering");
      },
      created: function () {
        this.templateRender = vueFunctions.render;
        this.$options.staticRenderFns = vueFunctions.staticRenderFns;
      }
    }
  }
  `
}

module.exports = function(source) {
  // ...
  const contentCode = compile("content", fmData.content);
  const excerptCode = compile("excerpt", fmData.excerpt);

  return `
    ${contentCode}
    ${excerptCode}
    export default {
      data: ${ JSON.stringify(fmData.data) },
      excerptComponent,
      contentComponent
    }`;
};
./src/components/Post.vueexport default {
  //...
  computed: {
    //...
    content() {
      return this.more ? this.post.contentComponent : this.post.excerptComponent;
    },
    //...
  }
}

I don't need the runtime compiler anymore.

./vue.config.jsmodule.exports = {
  // runtimeCompiler: true,
}

But my alias PostsDir does not work. Because the compiler transforms url that starts with ., ~, or @. @ is the vue-cli alias to ./src. So I can use @ as prefix for my alias. I changed PostsDir to @PostsDir everywhere. And images were processed. They all were build to ./dist/img/.

I don't like that place for posts' images. I want to see them next to posts. It means I have to change file-loader options.

./vue.config.jsmodule.exports = {
  chainWebpack: config => {
    //...
    const POSTS_DIR_RE = new RegExp(POSTS_DIR);
    config.module.rule('images').exclude.add(POSTS_DIR_RE).end();
    config.module.rule('svg').exclude.add(POSTS_DIR_RE).end();
    config.module.rule('postImages')
      .test(/\.(png|jpe?g|gif|webp|svg)(\?.*)?$/)
      .include.add(POSTS_DIR_RE).end()
      .use('file-loader')
        .loader('file-loader')
        .options({
          name: '[path][name].[hash:8].[ext]'
        }).end();
  },
}

Use Vue Loader

I can remove compilation from the loader and chain this operation to vue-loader.

./post_loader.jsconst grayMatter = require("gray-matter");
const markdownIt = require("markdown-it");
const escapeHtml = require("escape-html");

function wrap(code, lang = "text") {
  code = escapeHtml(code);
  return `<pre v-pre class="language-${lang}"><code>${code}</code></pre>`
}

const markdown = markdownIt({
  html: true,
  highlight: wrap
});

module.exports = function(source) {
  const fmData = grayMatter(source, { excerpt_separator: "<!-- more -->"});

  const fileName = this.resourcePath.substring(this.resourcePath.lastIndexOf("/") + 1);
  const slug = fileName.substring(0, fileName.lastIndexOf("."));
  fmData.data.slug = slug;

  if (fileName.endsWith(".md")) {
    fmData.content = markdown.render(fmData.content);
    if (fmData.excerpt) {
      fmData.excerpt = markdown.render(fmData.excerpt);
    }
  }
  if (!fmData.excerpt) {
    fmData.excerpt = fmData.data.description;
  }

  const result = `export default 
    <template>
      <div>
        <div v-if="!more">
          ${fmData.excerpt}
        </div>
        <div v-if="more">
          ${fmData.content}
        </div>
      </div>
    </template>

    <script>
    export default {
      fmData: ${ JSON.stringify(fmData.data) },
      props: {
        more: {
          default: false
        }
      }
    }
    </script>
    `;
  return result;
};
./vue.config.jsmodule.exports = {
  configureWebpack: config => {
    //...
      rules: [
        {
          test: new RegExp(POSTS_DIR + ".+\.(md|html)$"),
          use: [
            'vue-loader',
            {
              loader: path.resolve(__dirname, "post_loader.js"),
            }
          ]
        }
      ]
    //...
  },
  chainWebpack: config => {
    //...
    const POSTS_DIR_RE = new RegExp(POSTS_DIR);
    //...
    config.module.rule('eslint').exclude.add(POSTS_DIR_RE);
  },
}
./src/components/Post.vue<template>
  ...
  <component :is="content" v-bind="contentProps"></component>
  ...
</template
<script>
export default {
  //...
  computed: {
    content() {
      return this.post;
    },
    contentProps() {
      return {more: this.more};
    },
    //...
  }
}
</script>

Then I have to find all occurrences of post.data and change it to post.fmData.

Now while I am editing my post, it will hot-update in browser.

Highlight code

I use Prism to highlight code blocks in my posts.

npm install -D prismjs

I can use it in the loader. markdown-it has the option to set a function that will highlight code.

./post_loader.jsconst prism = require("prismjs");
const prismLoadLanguages = require("prismjs/components/");

prismLoadLanguages(["html", "css", "md", "js", "java", "bash", "rust"]);

function highlight(str, lang = "text") {
  let code;
  if (prism.languages[lang]) {
    code = prism.highlight(str, prism.languages[lang], lang);
  } else {
    code = escapeHtml(str);
  }
  return `<pre v-pre class="language-${lang}"><code>${code}</code></pre>`;
}

const markdown = markdownIt({
  html: true,
  highlight: highlight
});

I call escapeHtml just for code that is not processed by Prism. Prism do it for other code.

But what about html files? I have to find code blocks and edit html tree. But NodeJS does not have DOM. There are packages that can parse html and play with tree. I recommend jsdom. It allows to highligh whole document.
Other libs like cheerio give you pseudo Elements. There are a few methods from api. But it is not enough and Prism can not work with this elements. Anyway, your code inside <code> must be escaped. This libs try to fix all, which looks like html. So you can't run Prism with Element and can't run it with this escaped code like I did with markdown.

npm install -D jsdom
./post_loader.jsconst jsdom = require("jsdom");
const { JSDOM } = jsdom;

function highlightHtml(html) {
  const document = new JSDOM(html).window.document;
  prism.highlightAllUnder(document, false);
  return document.body.innerHTML;
}

module.exports = function(source) {
  //...
  if (fileName.endsWith(".md")) {
    fmData.content = markdown.render(fmData.content);
    if (fmData.excerpt) {
      fmData.excerpt = markdown.render(fmData.excerpt);
    }
  } else if (fileName.endsWith(".html")) {
    fmData.content = highlightHtml(fmData.content);
    if (fmData.excerpt) {
      fmData.excerpt = highlightHtml(fmData.excerpt);
    }
  }
  //...
}

An escaping code by hands is pain. It is the reason to use markdown for posts about programming and don't use Blogger.

Move Prism from the loader

There is another way to use Prism. I will call Prism after page has been ready and components have been mounted. And remove the highlighting from the loader. In the loader I will just escape html for markdown.

npm install -D @vue/cli-plugin-babel babel-plugin-prismjs
./post_loader.jsfunction wrap(code, lang = "text") {
  code = escapeHtml(code);
  return `<pre v-pre class="language-${lang}"><code>${code}</code></pre>`
}

const markdown = markdownIt({
  html: true,
  highlight: wrap
});
./src/components/Post.vue<script>
import Prism from "prismjs";
Prism.manual = true;
//...
export default {
  //...
  mounted() {
    this.$nextTick(() => {
      Prism.highlightAll(false);
    });
    
  },
  //...
}
</script>
./babel.config.jsmodule.exports = {
  plugins: 
    [
      ["prismjs", {
          "languages": [
            "html",
            "css",
            "scss",
            "md",
            "js",
            "java",
            "bash",
            "rust",
            "properties",
            "json"
          ],
          "css": false,
          // "theme": "tomorrow"
          
      }]
    ]
}
./src/styles/style.scss@import '~prismjs/themes/prism-tomorrow.css'

I import Prism styles to my main style file. But you can activate it with babel plugin.
I use $nextTick because Post component has the child component, my post content. I should wait it to highlight it. And the prerender will wait when Prism finishes.
Hot reload for code blocks may not work after Prism changes. Because Prism manipulates with DOM. May be it is a reason to revert back and use prism in the loader.

Out of memory

I added 100 posts like this. And Node.js crashed with error. It means that this solution will not work in future. I think that problem is because

import posts from "@PostsDir";

I can't import all posts together. I will try dynamic import.

Post dynamic import

In router I added one path /posts/:slug with parameter slug. And slug is file name. My slug is file name without extension. But I can use full file name. So in Post.vue I can import just this file. posts is array of just frontmater. I use it to check that slug exist. This array I set to process.env.VUE_APP_POSTS in vue.config.js. Also I created process.env.VUE_APP_TAGS.

./router/index.jsconst posts = JSON.parse(process.env.VUE_APP_POSTS);
//...
routes = [
  //...
  {
    path: "/posts/:slug",
    name: "post",
    component: Post,
  },
  {
    path: "/404",
    name: "NotFound",
    component: NotFound,
    alias: '*'
  }
];
//...
router.beforeEach((to, from, next) => {
  if (to.params["slug"]) {
    const slug = to.params["slug"];
    const post = posts.find((p) => p.slug == slug)
    if (!post) {
      next({name: "NotFound", replace: true});
      return;
    }
  }
});
./src/components/Index.vueconst allPosts = JSON.parse(process.env.VUE_APP_POSTS);
//...
./src/components/Tags.vue<script>
export default {
  computed: {
    tags() {
      return JSON.parse(process.env.VUE_APP_TAGS);
    }
  }
}
</script>
./src/components/Post.vue<script>
{
  const posts = JSON.parse(process.env.VUE_APP_POSTS);
  // ...
  data() {
    let post = this.postData;
    if (!post) {
      const slug = this.$route.params["slug"];
      post = posts.find((p) => p.slug == slug);
    }
    import("@PostsDir/" + post.slug)
    .then(({default: p}) => {
      this.component = p;
    });
    return {
      post,
      component: null
    }
  },
  computed: {
    fm() {
      return this.post;
    },
    contentComponent() {
      if (this.component) {
        return this.component;
      } else {
        return {
          render(createElement) {
            return createElement("div", "Rendering...");
          }
        };
      }
    },
  // ...
}
</script>

Renderer

Now I need to configure renderer to work with big amount of pages and wait dynamic content.

./vue.config.js//...
renderer: new Renderer({
  injectProperty: '__PRERENDER_INJECTED',
  inject: {
    prerendered: true
  },
  navigationOptions: {
    waitUntil: 'load',
    timeout: 0
  },
  maxConcurrentRoutes: 4,
})
//...

Event load happens after code from dynamic chunk has been executed. I limit concurrent routes because chromium takes a lot of memory and crashes. If chromium works slow, timeout can be reached. So I disabled timeout.

Remove script chunks from static pages

Webpack generates chunks for dynamic posts. I should remove them.

.src/main.jswindow.addEventListener('load', (event) => {
  if (window.__PRERENDER_INJECTED && window.__PRERENDER_INJECTED.prerendered) {
    document.getElementById("spaScripts").remove();
    Array.from(document.getElementsByTagName("script"))
    .filter((s) => s.src.indexOf("chunk") >= 0)
    .forEach((s) => s.remove());
  }
});

Other files in @PostsDir

I keep other files next to posts (java, pdf, etc). With dynamic import in code I get warning:

warning in ./posts/00001/Somefile.java
Module parse failed: The keyword 'public' is reserved (2:0)
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
@ ./posts lazy ^./.*$ namespace object
@ ./node_modules/cache-loader/dist/cjs.js??ref--12-0!./node_modules/babel-loader/lib!./node_modules/cache-loader/dist/cjs.js??ref--0-0!./node_modules/vue-loader/lib??vue-loader-options!./src/components/Post.vue?vue&type=script&lang=js&
@ ./src/components/Post.vue?vue&type=script&lang=js&
@ ./src/components/Post.vue
@ ./src/router/index.js
@ ./src/main.js
@ multi (webpack)-dev-server/client?http://192.168.13.112:8080/sockjs-node (webpack)/hot/dev-server.js ./src/main.js

To get rid of the warning I added posts dir to noParse. And I excluded resources with / in POSTS_DIR.

./vue.config.jsmodule.exports = {
  //...
            test: /\.(md|html)$/,
            include: path.resolve(__dirname, POSTS_DIR),
            exclude: new RegExp(POSTS_DIR + "[^\\/]+\\/"),
  //...
  chainWebpack: config => {
    //...
    const POST_IMAGES_RE = /\.(png|jpe?g|gif|webp|svg)$/;
    const noParseDefault = config.module.get("noParse");
    config.module.set("noParse", (content) => {
      let defaultResult = false;
      if (typeof noParseDefault === "function") {
        defaultResult = noParseDefault(content);
      } else {
        defaultResult = noParseDefault.test(content);
      }
      return defaultResult
        || new RegExp(POSTS_DIR + ".+\/").test(content) && !POST_IMAGES_RE.test(content);
    });
  //...
  }
}

Then I get another error at runtime.

SyntaxError: export declarations may only appear at top level of a module

So in the loader I changed export default by module.exports =.

Speed is low

Now I can build 100 posts. But it works slow. It takes 2 minutes to start npm run serve. I decided to throw away vue-loader and don't compile components. I returned back to images with hardcoded urls. After changes the build time decreased to 5 seconds. And I do the highlighting in the loader. It is fast enough.

Images again

I can resolve relative image urls with html-loader and extract-loader.

./post_loader.jsconst htmlLoader = require("html-loader");
const extractLoader = require("extract-loader");
//...
module.exports = {
  //...
  fmData.content = oneRootElement(fmData.content);
  fmData.excerpt = oneRootElement(fmData.excerpt);

  let callback = this.async();

  let excerpt = htmlLoader.call(this, fmData.excerpt);
  let content = htmlLoader.call(this, fmData.content);
  const excerptPromise = extractLoader.call(
    Object.assign({}, this, {
      async: () => { return (err, result) => {excerpt = result;} }
    }),
    excerpt);
  const contentPromise = extractLoader.call(
    Object.assign({}, this, {
      async: () => { return (err, result) => {content = result;} }
    }),
    content);
  Promise.all([excerptPromise, contentPromise]).then(() => {
    callback(null, `module.exports = ${ JSON.stringify({excerpt, content}) }`)
  });
  return;
}

My images are placed in @PostsDir. So relative path ./00008/window.png works in a post html. But @PostsDir/00008/window.png does not work. There is bug 185. As recomended ~@PostsDir/00008/window.png should work, but it doesn't.

This workaround looks hacky. Calling another loader from loader is strange. I don't chain loaders in the webpack config because my loader returns two html strings: excerpt and whole content. I should split excerpt and content. I need two imports for one resource. I will use resource query.

./src/components/Post.vue// ...
let contentPromise;
if (this.more) {
  contentPromise = import("@PostsDir/" + post.slug + "?more=true");
} else {
  contentPromise = import("@PostsDir/" + post.slug);
}
contentPromise.then(({default: html}) => { this.content = html; });
//...
./post_loader.jsconst qs = require("querystring");

const query = qs.parse(this.resourceQuery.slice(1));
let content;
if (query.more == "true") {
  content = fmData.content;
} else {
  content = fmData.excerpt;
}
//... markdown and html processing
content = oneRootElement(content);
return content;
./vue.config.jsrules: [
  {
    test: /\.(md|html)$/,
    include: path.resolve(__dirname, POSTS_DIR),
    exclude: new RegExp(POSTS_DIR + "[^\\/]+\\/"),
    use: [
      "html-loader",
      { loader: path.resolve(__dirname, "post_loader.js") }
    ]
  }
]

Now images works and ~@PostsDir/00008/window.png works too. extract-loader is unnecessary.

Partial build

Remember that my pagination is when the oldest post is on page 1. And the page /index.html is not filled to the POSTS_PER_PAGE. Root page may has only one post. It means new post does not change content of all previous pages. Therefore, to add one new post I don't need to rerender all posts and pages again.

I added two arguments for build script.

#build post with number 00008
npm run build -- --post-number=8

#build posts with date >= 2020-01-01
npm run build -- --post-date=2020-01-01

In vue.config.js I filter routes that show posts specified by parameters. And only this routes will be added to prerender plugin.

./vue.config.jsconst postNumberRe = /^--post-number=(\d+)$/;
const postDateRe = /^--post-date=([\d-]+)$/;
let postNumberArg = process.argv.find((arg) => postNumberRe.test(arg));
let postDateArg = process.argv.find((arg) => postDateRe.test(arg));
if (postNumberArg) {
  postNumberArg = postNumberArg.replace(postNumberRe, "$1").padStart(5, "0");
} else if (postDateArg) {
  postDateArg = new Date(postDateArg.replace(postDateRe, "$1"));
}

function createPagesForPosts (posts, base) {
  const pages = [];
  const maxIndex = Math.ceil(posts.length / POSTS_PER_PAGE);
  for (let i = 1; i < maxIndex; i++) {
    pages.push(base + "page/" + i);
  if (!postDateArg && !postNumberArg) {
    for (let i = 1; i < maxIndex; i++) {
      pages.push(base + "page/" + i);
    }
    pages.push(base);
  } else {
    posts = posts.sort((a, b) => new Date(b.date) - new Date(a.date));
    for (let i = 1; i <= maxIndex; i++) {
      const pagePosts = posts.slice(Math.max(0, posts.length - i * POSTS_PER_PAGE),
          posts.length - (i - 1) * POSTS_PER_PAGE);
      let existsonpage = false;
      if (postNumberArg) {
        existsonpage = pagePosts.some((p) => p.slug.startsWith(postNumberArg));
      } else if (postDateArg) {
        existsonpage = pagePosts.some((p) => new Date(p.date) >= postDateArg);
      }
      if (existsonpage) {
        if (i == maxIndex) {
          pages.push(base);
        } else {
          pages.push(base + "page/" + i);
        }
      }
    }
  }
}

fs.readdirSync(POSTS_DIR, {withFileTypes: true})
  .filter((file) => {
    return file.isFile() && /^.+\.(md|html)$/.test(file.name);
  })
  .forEach((file) => {
    const fileName = file.name;
    const fmData = grayMatter.read(POSTS_DIR + fileName, {});
    fmData.data.slug = fileName;
    allPosts.push(fmData.data);
    
    const slug = fileName;
    const date = new Date(fmData.data.date);
    if (!postNumberArg && !postDateArg
      || postNumberArg && slug.startsWith(postNumberArg)
      || postDateArg && date >= postDateArg) {
      if (!fmData.data.url) {
        routes.push("/posts/" + slug);
      }
      if (fmData.data.tags) {
        tags.push(fmData.data.tags);
      }
    }
  });
routes.push(...createPagesForPosts(allPosts, "/"));
// tags code without changes
//...

If I run

npm run build -- --post-number=8

Routes for prerender plugin will be

[
  '/posts/00008_javafx_custom_window.md',
  '/page/1',
  '/tag',
  '/tag/java/'
]

Conclusion

Vue.js and PrerenderSpaPlugin are more better for me than VuePress. I have more freedom. I feel I can do with this configuration what I want. I like the ability to customize my blog.

My customization gives me advantages:

  • wright order with pagination
  • dehydrated pages
  • build time
  • partial build
  • html as source for post

I shared the result of my experiments (link).