I made this blog back in 2012, and at this time I discovered an awesome framework called Bolt CMS. It was created by a small web agency based in the Netherlands where I had the chance to do my internship. The CMS is built out of Symfony and uses Twig as a templating engine. It was a perfect fit for me as I was learning HTML, CSS and PHP at this time.
Why moving from Bolt CMS to a Markdown generated blog?
CMS are great don’t get me wrong. But they have a particular target which is to make super simple content management for non technical people.
The main issue of running a CMS is quite obvious, you need to host it somewhere and it needs to be maintained. One of my principal concern was to make sure that the CMS was always up to date and secure. Which is, when you don’t blog often, hard.
I also wanted to have a blog that is super fast (even tho’ Bolt performances were crazy), to be able to write my posts in Markdown and to be able to version them with Git.
How did I do it?
Firstly, I looked around what kind of static website generators were available. I tried a few of them (mostly in NodeJS but they were not maintained that well), but I ended up using Hugo . It’s a super fast static website generator written in Go. It’s also super easy to use and has a lot of themes available.
One thing that I wanted to mention is how simple it is to install hugo on any system without even bothering wether it’s made in Go or not.
Migrating the design
Secondly, I wanted to stick as much as possible to the design I had on Bolt. Thankfully, the templating engine used by Bolt is quite close to the Go template used by Hugo.
For instance this is how you would display a post title in Bolt:
{{ record.title }}
And this is how you would do it in Hugo:
{{ .Title }}
And it’s pretty much the same thing for looping over your content:
{% for record in records %}
{{ record.title }}
{% endfor %}
versus
{{ range .Pages }}
{{ .Title }}
{{ end }}
On my previous theme, I was using Gulp to process the JS and the SCSS files. Let’s be honest Gulp is quite dated and barely maintained.
But guess what? The extended version of Hugo has a built-in asset pipeline that can process your JS and SCSS files. It’s fast and easy to use. I was able to remove Gulp and all the dependencies that came with it.
Now you can play to spot the differences:
Migrating the content
Thirdly, I then had to migrate all my posts from Bolt (it was an SQLite database) to Hugo. I used the Bolt CMS extension called Conimex to export all my database in YAML format and then converted it to JSON because it’s much easier to parse.
From this I created a script to convert all my previous posts written in HTML and their metadata to Hugo Markdown format. I also had to migrate all the images and files that were uploaded to Bolt. I included in the script a step that would download all the files and put them in the right folder.
Here is the script I used to convert the posts:
1const fs = require('fs');
2
3const entriesFile = fs.readFileSync('./entries.json');
4
5const file = JSON.parse(entriesFile);
6
7const entries = file.content;
8
9if (!fs.existsSync('./output')) {
10 fs.mkdirSync('./output');
11}
12
13const createFile = (entry) => {
14 const { fields, taxonomies, createdAt, status } = entry;
15 let { title, body, image, slug } = fields;
16 const { tags } = taxonomies;
17 const { filename } = image;
18
19 if (status !== 'published') {
20 return;
21 }
22
23 const tagsStr = tags ? JSON.stringify(Object.keys(tags)) : null;
24
25 const outputPath = `./output/${slug}`;
26
27 if (!fs.existsSync(outputPath)) {
28 fs.mkdirSync(outputPath);
29 }
30
31 const filePath = `./files/${filename}`;
32 const ext = filename.split('.').pop();
33 const newFilename = `featured.${ext}`;
34 if (filename) {
35 const newFilePath = `${outputPath}/${newFilename}`;
36 fs.copyFileSync(filePath, newFilePath);
37 }
38
39 const regex = /<img[^>]+src="([^">]+)"/g;
40 const matches = body.match(regex);
41
42 if (matches) {
43 matches.forEach((match) => {
44 const regex2 = /src="([^">]+)"/;
45 const match2 = match.match(regex2);
46 const imgPath = match2[1];
47
48 const imgPath2 = decodeURIComponent(imgPath).split('?')[0];
49
50 // /files/2013-08/samsung-galaxy-s3.jpg
51 const regex3 = /\/files\/(.*)/;
52 const match3 = imgPath2.match(regex3);
53 if (match3) {
54 const imgFilename = match3[1];
55 const imgFilename2 = imgFilename.split('/').pop();
56
57 const newImgPath = `${outputPath}/${imgFilename2}`;
58 fs.copyFileSync(`./files/${imgFilename}`, newImgPath);
59 console.log(imgPath, imgFilename2);
60 body = body.replace(imgPath, encodeURIComponent(imgFilename2));
61 }
62
63 // /thumbs/1000×1000×max/Screenshot-20230128-171305.png
64 const regex4 = /\/thumbs\/.*\/(.*)/;
65 const match4 = imgPath2.match(regex4);
66 if (match4) {
67 const imgFilename = match4[1];
68 const imgFilename2 = imgFilename.split('/').pop();
69 const newImgPath = `${outputPath}/${imgFilename2}`;
70 fs.copyFileSync(`./files/${imgFilename}`, newImgPath);
71 console.log(imgPath, imgFilename2);
72 body = body.replace(imgPath, encodeURIComponent(imgFilename2));
73 }
74 });
75 }
76
77 const TurndownService = require('turndown');
78 const turndownService = new TurndownService({
79 headingStyle: 'atx',
80 hr: '* * *',
81 bulletListMarker: '*',
82 codeBlockStyle: 'fenced',
83 fence: '```',
84 emDelimiter: '_',
85 strongDelimiter: '**',
86 linkStyle: 'inlined',
87 linkReferenceStyle: 'full',
88 br: ' ',
89 blankReplacement: function (content, node) {
90 return node.isBlock ? '\n\n' : '';
91 },
92 keepReplacement: function (content, node) {
93 return node.isBlock ? '\n\n' + node.outerHTML + '\n\n' : node.outerHTML;
94 },
95 defaultReplacement: function (content, node) {
96 return node.isBlock ? '\n\n' + content + '\n\n' : content;
97 }
98 });
99
100 turndownService.keep(['iframe', 'video']);
101
102 const result = turndownService.turndown(body);
103
104 const content = `+++
105title = "${title}"
106slug = '${slug}'
107aliases = ['/post/${slug}']
108date = '${createdAt}'
109draft = false
110tags = ${tagsStr ?? '[]'}
111image = '${filename ? newFilename : ''}'
112+++
113
114${result}
115`;
116
117 fs.writeFileSync(`./output/${slug}/index.md`, content);
118};
119
120entries.forEach((entry) => {
121 createFile(entry);
122});
It’s probably not the best piece of code I’ve ever written but it did the job. And the funniest part is that it was mostly written by Github Copilot which is an amazing tool when it comes to write crappy but working code.
Turndown
module was used to convert the HTML to Markdown. It’s a great module but it’s not perfect. I had to do some manual fixes on some posts like removing some bad spacing chars and making sure the iframe and video tags were not removed as there is no equivalent in Markdown.
For the image that are in the content, I used a regex to find the image path and then I copied the file to the new folder. I also had to replace the image path in the content with the new filename. It needed some manual fixes but I think I covered most of the cases.
Migrating the comments
Well, cool thing that I am using utterances for the comments. It’s a Github issues based comment system. So I didn’t have to migrate anything.
I highly recommend using it if you want to have a comment system that is simple and hosted for free on your blog.
Migrating hosting provider
For Bolt CMS, I was using my own server hosted at OVH. As for Hugo, I moved to Cloudflare Pages which is, in my opinion, the best free tier offer for static websites hosting.
Cloudflare Pages has a nice documentation on how to deploy a Hugo website and they also support the extended version of Hugo which is perfect for me as I’m using the SASS compilation.
It also supports custom domains and SSL certificates for free. If you use an APEX domain (like I do) you will likely need to move to Cloudflare DNS to be able to use it. The good thing is that you can then benefit from the security and performance features of Cloudflare.
You can also use the Bulk Redirect tool to redirect the *.pages.dev domain to your custom domain.
The result!
Here is how it looks like now to edit content:
Bonus : creating new posts easily
I built a simple script to allow me to create a new post easily. It will create a new folder with the current year and place a new Markdown file pre-filled with the post title slug and default metadata.
1const fs = require('fs');
2const path = require('path');
3
4const title = process.argv[2];
5
6if (!title) {
7 console.error('Please provide a title for the post');
8 process.exit(1);
9}
10
11const slug = title.toLowerCase().replace(/ /g, '-');
12const date = new Date().toISOString();
13const year = date.split('-')[0];
14const folderName = `${year}/${slug}`;
15const filePath = path.join('content/posts', folderName, 'index.md');
16
17if (fs.existsSync(filePath)) {
18 console.error('Post already exists');
19 process.exit(1);
20}
21
22const fileContent = `+++
23title = "${title}"
24slug = '${slug}'
25aliases = ['/post/${slug}']
26date = '${date}'
27draft = false
28tags = []
29image = 'featured.jpeg'
30+++
31`;
32
33fs.mkdirSync(path.join('content/posts', folderName), { recursive: true });
34fs.writeFileSync(filePath, fileContent);
35
36console.log('Post created successfully');
It’s basic but very efficient and it saves me a lot of time copy pasting the metadata and creating the folder structure.
Conclusion
It took me around 5 hours to migrate from Bolt to Hugo. I’m super happy with the result and I’m looking forward to writing more posts.
If you want to see the full project you can check it out on GitHub .
In a future post I’ll share some optimization tips with Hugo I used to make this blog faster.