This guide is a continuation of this blog. You can start with it before continuing with this post. The static site generator we are going to make will search for markdown files that exist within folders, convert their content to HTML content then use the converted HTML to generate HTML files. You can find the repo with this project here.
At the end of this guide, your project structure will look something like this:
├── pages
└── about.md
├── posts
└── sample.md
├── public
├── pages
├── posts
└── index.html
├── parser.js
├── rules.js
└── server.js
We can start by making our rules for converting some patterns found in markdown into html. We will use Regular Expressions (regex) to check for the patterns. The patterns we will be looking out for are:
You can add more more patterns if you are familiar with regex patterns. We will store these rules in rules.js
file:
const rules = [
//header rules
[/^#{6}\s?([^\n]+)/gm, "<h6 class='mb-3 font-medium'>$1</h6>"],
[/^#{5}\s?([^\n]+)/gm, "<h5 class='mb-3 text-lg font-medium'>$1</h5>"],
[/^#{4}\s?([^\n]+)/gm, "<h4 class='mb-3 text-xl font-medium'>$1</h4>"],
[
/^#{3}\s?([^\n]+)/gm,
"<h3 class='mb-3 capitalize text-2xl font-medium'>$1</h3>",
],
[
/^#{2}\s?([^\n]+)/gm,
"<h2 class='mb-5 capitalize text-3xl font-medium'>$1</h2>",
],
[
/^#{1}\s?([^\n]+)/gm,
"<h1 class='mb-5 capitalize text-4xl font-medium'>$1</h1>",
],
//bold, italics
[/\*{2}\s?([^\n]+)\*{2}/g, "<b>$1</b>"],
[/\*\s?([^\n]+)\*/g, "<i>$1</i>"],
[/_{2}([^_]+)_{2}/g, "<b>$1</b>"],
[/_([^_`]+)_/g, "<i>$1</i>"],
//code block highlight
[
/^```([\s\S]*?)^```$/gm,
'<div class="py-1 px-2 bg-gray-500 rounded-sm"><code style="color:white;text-decoration: none;">$1</code></div><br />',
],
//code highlight
[
/[^`]`{1}(\s?[^\n`]+\s?)`{1}/g,
'<span><code style="background-color:grey;color:white;text-decoration: none;border-radius: 3px;padding:1px 2px;">$1</code></span>',
],
// blockquote
[
/\n\>+\s?([^\n]+)/g,
"<blockquote class='p-4 my-4 border-l-4 border-gray-300 bg-gray-50'><p>$1</p></blockquote>",
],
//Lists
[/\n\+\s?([^\n]+)/g, "<ul><li>• $1</li></ul>"],
[/\n\*\s?([^\n]+)/g, "<ul><li>• $1</li></ul>"],
//Image
[
/\!\[([^\]]+)\]\((\S+)\)/g,
'<img src="$2" alt="$1" style="width: 100%; height: 100%; object-fit: contain;" />',
],
//links
[
/\[([^\n]+)\]\(([^\n]+)\)/g,
'<span><a href="$2" class="text-blue-500">$1</a></span>',
],
//paragragh
[/^.+[\r\n]+(\r?\n|$)/gm, "<p>$&</p>"],
];
exports.rules = rules;
For any rule (surrounded by square brackets []), the first half (all content found before the comma inside the square brackets) represents the regex pattern. The second half (content after the comma) represents what we will replace the regex pattern with. $1
, $2
... represent capturing groups, read this to understand capturing/matched groups. $&
represents the entire matched string.
You can read this article to understand how regex patterns work. Also, you should know that a regex pattern can be written in different ways, so it doesn't have to exist in a single way. Also, you can use this tool to help you come up with regex patterns.
You can add this inside sample.md
inside the posts
folder found at the root of the project(./posts/sample.md
):
### Sample markdown file
**This should be bold**
__This should also be bold__ and *this should be in italics*
This is a `code highlight`
> blockquote text
>>> blockquote text
+ list item 1
* list item 2
![sample image](https://images.unsplash.com/photo-1657253986186-9b86948daf36?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2070&q=80)
This is a [link](https://github.com/brayo333/static-site-generator)
You can add this inside about.md
inside the pages
folder found at the root of the project(./pages/about.md
):
# About
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc ullamcorper maximus posuere. Integer ante augue, maximus quis semper eu, imperdiet sed risus. Nam tincidunt leo mauris, sed accumsan ante egestas sed. Sed ultrices, tellus eu mattis molestie, turpis sem gravida est, finibus aliquet lacus ante quis leo. Aliquam quis eleifend neque. Quisque a lectus finibus, congue augue ut, rutrum ligula. Donec condimentum eu odio ut luctus. Nullam ultrices luctus eleifend. Aliquam mattis pretium tellus eget vehicula. Suspendisse vel mollis nisl. In ultrices pulvinar posuere. Phasellus maximus mi ut ultricies sodales. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Mauris pellentesque et nulla non imperdiet. Curabitur tincidunt ipsum a orci scelerisque viverra.
NB: You need to use kebab-case when naming any markdown files you will add inside posts or pages. Also the markdown files added need to have some content for the HTML files to be generated.
Inside the root of your project, create a folder named public
and inside it create two folders: pages
and posts
. The next file we are going to create will need this public folder to exist so that the generated HTML files can be placed inside.
For this, we will need a Javascript file which will be run any time we want to convert the markdown files into HTML files. We will create a file called parser.js
at the root of the project and add the following content(I have tried to explain the functions inside comments):
// path module allows us to work with directories & file paths e.g: getting file name or extension
const path = require("path");
// fs module allows us to interact with the file system
const fs = require("fs");
const { rules } = require("./rules");
const EXTENSION = ".md";
// Function is used to generate title names, used in making page links, from the kebab-case file name
function seperateWord(value) {
var title = "";
var temp = value.split("-");
title = temp.join(" ");
return title;
}
// Function used for paths creation on the html files
async function checkForFiles() {
var contents = [[], []];
try {
const pages = await fs.promises.readdir("pages");
for (let file of pages) {
try {
if (path.parse(file).ext === EXTENSION) {
// if (directoryName == "pages")
contents[0].push({ name: path.parse(file).name });
// console.log(path.parse(file).name);
}
} catch (error) {
console.log(error.message);
}
}
const posts = await fs.promises.readdir("posts");
for (let file of posts) {
try {
if (path.parse(file).ext === EXTENSION) {
// if (directoryName == "pages")
contents[1].push({ name: path.parse(file).name });
// console.log(path.parse(file).name);
}
} catch (error) {
console.log(error.message);
}
}
return contents;
} catch (error) {
console.log(error);
}
}
// The function provides html templating based on whether the source md file was meant for a post or a page
function HTMLTemplate(type, htmlContent, fileName) {
let finalContent;
let pageLinks = "";
checkForFiles().then(
(data) => {
for (let i = 0; i < data[0].length; i++) {
// here we are generating all the page links that will exist for the page html files generated
pageLinks = pageLinks.concat(
"<a href=",
`/${data[0][i].name}`,
" class='capitalize mt-3'>",
seperateWord(data[0][i].name),
"</a>"
);
}
finalContent = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>This is a ${
type == "posts" ? "post" : "page"
}</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<div class="w-full grid grid-col grid-cols-5 gap-3">
<nav class="col-span-1 h-screen p-5 flex flex-col">
<div class="w-full h-full flex flex-col overflow-y-auto">
<a href="/">Home</a>
${pageLinks}
</div>
</nav>
<div class="col-span-4 w-full max-w-7xl p-5 flex flex-col">
${htmlContent}
</div>
</div>
</body>
</html>`;
writeHTMLFile(`public/${type}/${fileName}.html`, finalContent);
},
(error) => {
console.log(error);
}
);
}
// The function adds the html content passed to it, after conversion from markdown, into a html file with file path specified
function writeHTMLFile(filePath, htmlContent) {
fs.open(filePath, "w+", function (err, fd) {
if (err) {
return console.error(err);
}
fs.writeFile(filePath, htmlContent, function (err) {
if (err) console.log(err);
else console.log(`File '${filePath}' has been created or updated`);
});
fs.close(fd, function (err) {
if (err) throw err;
});
});
}
// The function loops through a directory/folder and looks for md files & converts the content into html content
function loopDirectoryMDFiles(directoryName) {
fs.readdir(directoryName, (err, files) => {
if (err) throw err;
for (let file of files) {
if (path.parse(file).ext === EXTENSION) {
fs.open(`${directoryName}/${file}`, "r", function (err, fd) {
if (err) {
return console.error(err);
}
var buffr = new Buffer.alloc(10240);
fs.read(fd, buffr, 0, buffr.length, 0, function (err, bytes) {
if (err) throw err;
// If md file is not empty write html file
if (bytes > 0) {
let html = buffr.slice(0, bytes).toString();
rules.forEach(([rule, template]) => {
html = html.replace(rule, template);
});
html = HTMLTemplate(directoryName, html, path.parse(file).name);
// writeHTMLFile(
// `public/${directoryName}/${path.parse(file).name}.html`,
// html
// );
}
fs.close(fd, function (err) {
if (err) throw err;
});
});
});
}
}
});
}
// Function for writing the home page
function writeIndexHTML() {
var indexFile;
var pageLinks = "";
var postsLinks = "";
checkForFiles().then(
(data) => {
// here we are generating all the page links that will exist for the page html files generated
for (let i = 0; i < data[0].length; i++) {
pageLinks = pageLinks.concat(
"<a href=",
`/${data[0][i].name}`,
" class='capitalize mt-3'>",
seperateWord(data[0][i].name),
"</a>"
);
}
// here we are generating all the post links that will exist for the post html files generated
for (let i = 0; i < data[1].length; i++) {
postsLinks = postsLinks.concat(
"<div class='w-full mb-5'><a href=",
`/posts/${data[1][i].name}`,
" class='flex flex-col capitalize text-xl font-medium'>",
seperateWord(data[1][i].name),
"<div class='mt-3'><button class='bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded'>See post</button></div></a><hr class='h-[2px] mt-2 bg-blue-500' /></div>"
);
}
indexFile = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Website made using a custom static site generator</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<div class="w-full grid grid-col grid-cols-5 gap-3">
<nav class="col-span-1 h-screen p-5 flex flex-col">
<div class="w-full h-full flex flex-col overflow-y-auto">
<a href="/">Home</a>
${pageLinks}
</div>
</nav>
<div class="col-span-4 w-full max-w-7xl p-5 flex flex-col">
<div class="w-full flex flex-col">
${postsLinks}
</div>
</div>
</div>
</body>
</html>`;
writeHTMLFile("public/index.html", indexFile);
},
(error) => {
console.log(error);
}
);
}
// Convert the posts markdown files
loopDirectoryMDFiles("posts");
// Convert the pages markdown files
loopDirectoryMDFiles("pages");
// Write the index file for the Home page
writeIndexHTML();
You can run this file to generate the HTML files (index.html included) by typing the following command in a terminal after changing directory to where the project exists:
node parser #or node parser.js
If you had followed this blog, the next step is running the following command in a terminal:
node server #or node server.js
You should be able to preview your website locally in a browser.
I hope this guide has given you an idea of how you can make a static site generator using Node.js. You can let me know in the comments if something wasn't clear or if you need help. Also, you can find the repo for a sample project here.