Manually creating images for blog posts can be super time-consuming. Here's the foundation necessary for automatically generating meta images for content in markdown files.
I've written two posts recently that I wanted to put together to make something that you could practically apply in the wild. The first post is an intro on how to generate images by drawing on Canvas with Node.js (not yet published). The second is a quick lesson on generating random markdown files.
What I'd like to do here is bring these together to create a script that will do the following:
image
key in the frontmatter.image
reference back to the post's frontmatter using the filename of the generated image.The code I'm working with here can all be found in this example project.
Usually I take these things step-by-step, building up to the final product. In this case, there's a lot going on.
Instead of the typical step-by-step instructions, we'll going to walk through the finished product and look at each pieces of the puzzle. I've broken up the code to support this approach — every file has one job to do (the classic single responsibility principle).
That said, if you like following along step-by-step, you can absolutely start from scratch.
If you are starting from scratch, follow my handy guide to get setup with JavaScript projects.
Here are the dependencies to install.
npm install canvas faker glob gray-matter slugify yaml
And the scripts look like this.
package.json
{
"scripts": {
"generate:images": "node scripts/generate-images.js",
"generate:files": "node scripts/generate-post-files.js"
}
}
I also wanted to extract the configurable values into a single place. So I created a config.js
file in the root of the project.
config.js
const path = require("path");
module.exports = {
imagesDir: path.join(__dirname, "./images"),
postsDir: path.join(__dirname, "./content"),
randomPostCount: 10,
};
Last thing is to create the content
and images
directories (or the values you put for those directories in config.js
), where the markdown files and images will go. In this particular example, I dropped a .gitkeep
file in both and then ignored the generated markdown and image files.
Let's get our hands dirty by starting with generating random markdown files. Let's look at the script in scripts/generate-post-files.js
.
scripts/generate-post-files.js
const { generateRandomPost, writePostToFile } = require("../utils");
const config = require("../config");
Array(config.randomPostCount)
.fill()
.map(() => {
const post = generateRandomPost();
writePostToFile(post);
});
Doesn't look too complicated, right? Here's what's happening:
randomPostCount
value from config.js
and create an empty array with that many items which we can loop over.generateRandomPost()
helper, then write the random post object to file using a writePostToFile()
helper.Let's take a look at what those helpers are doing.
There's not a whole lot to the generateRandomPost()
function (in utils/generate-random-post.js
).
utils/generate-random-post.js
const faker = require("faker");
module.exports = () => {
return {
title: faker.lorem.words(5),
date: faker.date.past(1),
author: faker.name.findName(),
body: faker.lorem.paragraphs(3).replace(/\n/gi, "\n"),
};
};
It uses the faker.js library to generate some random content that we then shape into the structure of a post object.
Once we have a post object, we're ready to write it to file. This is done in utils/write-post-to-file.js
(see here).
In this function, we extract the body
from the post because it is treated as the main content area. The remaining attributes of the post are kept as frontmatter for the markdown file. We then convert the post object to a markdown string and write the string to a file, using a filename-friendly version of the title as the filename.
utils/write-post-to-file.js
const fs = require("fs");
const path = require("path");
const slugify = require("slugify");
const yaml = require("yaml");
const config = require("../config");
module.exports = (post) => {
// Format the markdown by extracting the `body` key and treating the rest of
// the object as frontmatter.
const { body } = post;
delete post.body;
const content = `---\n${yaml.stringify(post)}---\n\n${body}\n`;
// Resolve the path to the post file, using the value set in config.js in the
// project root.
const basename = slugify(post.title, { strict: true, lower: true });
const filename = `${basename}.md`;
const filePath = path.join(config.postsDir, filename);
// Write the markdown string to file.
fs.writeFileSync(filePath, content);
return post;
};
This is not checking for duplicate files. If there is a conflicting filename, this file simply overwrites the file in its way. If using this in a production capacity, you likely don't want to forcefully overwrite files like this.
Now that you see how it works, if you've copied the code above, you can try it for yourself.
npm run generate:files
This should place 10 files (or whatever count you have in config.js
) in your content
directory.
Here's an example of a file:
content/a-delectus-non-qui-quo.md
---
title: a delectus non qui quo
date: 2021-03-27T23:39:32.902Z
author: Marta Leffler
---
Rerum dolores occaecati iure dolorem quod harum quis. Sint et perferendis et et et. Ipsam qui aut qui modi iste natus placeat et. Officia animi illo labore autem tenetur id. Qui sit rerum cupiditate voluptas inventore repellat error. Labore aut ut consectetur sequi aut ducimus dolorem minus perferendis.
Quia totam ea deserunt consequatur optio eum. Illum voluptatibus consequatur. Mollitia nisi sunt tenetur impedit velit. Et omnis quia eveniet necessitatibus earum.
Nulla voluptatem et libero. Est consequatur tempora qui. Magnam voluptas nemo est id culpa omnis facilis qui.
The scripts/generate-images.js
file has a little more going on, but seems simple at first glance.
scripts/generate-images.js
const fs = require("fs");
const path = require("path");
const { generateImage, getPosts, writePostToFile } = require("../utils");
const run = async () => {
// Loop through the posts.
for (let post of getPosts()) {
// If the post already has an image reference, continue.
if (post.image) continue;
// Generate an image for the post.
const imagePath = await generateImage(post);
// Store a reference to the image.
post = { ...post, image: path.basename(imagePath) };
// Write the new post object back to file.
await writePostToFile(post);
}
};
run()
.then(() => console.log("Done"))
.catch((err) => {
console.error("\n", err);
process.exit(1);
});
Here's the logic:
getPosts()
helper to retrieve all the existing posts (from the content
directory).image
in its frontmatter, we ignore it.The the utils/get-posts.js
helper contains the following logic:
.md
files in the content
directory.content
directory.Here's the code:
utils/get-posts.js
const fs = require("fs");
const glob = require("glob");
const matter = require("gray-matter");
const path = require("path");
const config = require("../config");
module.exports = () => {
// Get post file paths.
const postsPattern = path.join(config.postsDir, "*.md");
const postFiles = glob.sync(postsPattern);
// Loop through the paths to parse the posts.
const posts = postFiles.map((file) => {
const fileContent = fs.readFileSync(file);
const { data, content } = matter(fileContent);
// `body` is set to the content of the post, while the frontmatter object is
// sent directly.
return { ...data, body: content };
});
// Return the array of objects.
return posts;
};
I'm not going to go into detail on this one. There's a lot going on, but it mostly involves some cleaned up code from my post for LogRocket. Suffice to say, it accepts a post object, generates an image for it, saves the image to the filesystem, and returns the path to that image.
Back in our image generator script, we've received the file path for the image, so we can simply add that to the post object that we got from getPosts()
, add the image
attribute to it, and then call the writePostToFile()
method (which we used when generating random posts) to write the new structure of the post back to file.
If you put this all together you can run it and see what happens.
npm run generate:images
This will generate images for any post that isn't already referencing one. If I used the files generated from the generate:files
script I shared above, I now have an image file at images/2021-03-27-a-delectus-non-qui-quo.png
. (The date was added to the image filename to help ensure it will be unique)
I pulled this from the LogRocket example, which is why the image is styled in this way.
And if I look back at content/a-delectus-non-qui-quo.md
I now see an image
reference.
content/a-delectus-non-qui-quo.md
---
title: a delectus non qui quo
date: 2021-03-27T23:39:32.902Z
author: Marta Leffler
image: 2021-03-27-a-delectus-non-qui-quo.png
---
Rerum dolores occaecati iure dolorem quod harum quis. Sint et perferendis et et et. Ipsam qui aut qui modi iste natus placeat et. Officia animi illo labore autem tenetur id. Qui sit rerum cupiditate voluptas inventore repellat error. Labore aut ut consectetur sequi aut ducimus dolorem minus perferendis.
Quia totam ea deserunt consequatur optio eum. Illum voluptatibus consequatur. Mollitia nisi sunt tenetur impedit velit. Et omnis quia eveniet necessitatibus earum.
Nulla voluptatem et libero. Est consequatur tempora qui. Magnam voluptas nemo est id culpa omnis facilis qui.
It takes a lot for this to all come together, but I hope it was helpful for you. I'd be curious to learn how you applied it to your project.