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)quot;),
use: [
{
loader: path.resolve(__dirname, "post_loader.js"),
}
]
}
]
}
}
I use loader only for posts folder.
"\.(md|html)
quot;
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.
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)quot;),
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.
in
vue.config.js
. Also I created
process.
.
./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 (github).