How to Automatically Generate Publish Dates for Your Astro Blog

10 minutes of your time

Ahoy, me hearties! Clamber aboard this fine ship, and let’s set sail while the winds be in our favor! We’ve got some clever contraptions to rig up and automation to unleash upon the seas! Arrrg, let’s get to it!

Alright lets skip the pirate lingo and start being serious, or not too serious, but I can’t keep using ChatGPT to generate anymore pirate lingo (you will probably know why soon).


This is my second post (crazy I have come this far), but I still manage to understand some pain points when writing a blog.

When writing a blog you don’t usually write one post at a time, you probably (at least for me) have multiple topics in your head, scribble notes laying around your desk, probably dozens of bookmarks (yikes) and half finished posts, that are not quite ready to be publish.

By publish, meaning you can read the post here on my blog. When writing posts, I like to have my local astro instance up and running to be able to see how the post will look like.

If you are new to astro and have followed their blog tutorial you end up having your posts in the following structure:

src/pages/posts
├── generate-publish-dates.md
└── i-built-a-blog.md

And then astro will (by default) generate routes for these files and also convert/build them to static HTML pages.

One issue I had (spoiler, I managed to solve it) was that my unfinished posts appeared on my site. One solution would be to write the actual post somewhere else, copy the contents, create a file, paste the content into the file, check if it looks good and then remove the file (so I don’t accidentally publish it to the site), cumbersome.

Luckily I stumbled across this astro post when searching for solutions. That gave me the inspiration for the following.

Let’s get our hands dirty, ye scallywags

Before we begin I want to highlight my requirements:

  1. Be able to hide my draft posts in production (still visible locally).
  2. Automatically generate pubDate when a posts gets published.

First thing to do is to migrate from page structure to content collections. Luckily there is a step-by-step tutorial , and when you are done you will have the following file structure for your posts:

src/content
├── config.js
└── posts
    ├── generate-publish-dates.md
    └── i-built-a-blog.md

One key change is the addition of a config.js file, where you are forced to add a zod schema , after the tutorial you end up with something like:

import { z, defineCollection } from "astro:content";

const postsCollection = defineCollection({
  type: "content",
  schema: z
    .object({
      title: z.string(),
      pubDate: z.date(),
      description: z.string(),
      tags: z.array(z.string()),
    })
});

In short when building the project npm run build astro will be validating the files in content/posts to check if every file has a title, pubDate, description and tags.

If not you will probably get an error message like:

[InvalidContentEntryFrontmatterError] [astro:content-imports] 
posts i-built-a-blog.md frontmatter does not match collection schema.
Title, description, pubDate, and tags are required.

Adding isPublished property

So the idea is to add a isPublished property to mark a post as published. Using that property I can create a utility function getAllPosts that utilizes astro’s getCollection :

import { getCollection } from "astro:content";

export const getAllPosts = async () => {
  const posts = await getCollection("posts", ({ data }) => {
    return data.isPublished === true;
  });
  return posts;
};

In short the above function filters out all posts that does not have isPublished set to true. But this alone does not fulfill my first requirement:

  1. Be able to hide my draft posts in production.

I still want to be able to see my draft posts when running astro locally npm run dev. Luckily this is easy to fix due to the fact that astro is using vite under the hood and we can add the following:

import { getCollection } from "astro:content";

export const getAllPosts = async () => {
  const posts = await getCollection("posts", ({ data }) => {
    return import.meta.env.PROD ? data.isPublished === true : true;
  });
  return posts;
};

When env is set to PROD, we apply a filter; otherwise, we return all posts. However, we don’t need to add this manually, as Vite handles it automatically for production builds.


Arrr, perfect it be! There is still one issue left, do you know what? We still need to update the zod schema! This is because draft posts may not have a pubDate, title, description, or even tags, but for published its a must!

So here is the updated schema:

const postsCollection = defineCollection({
  type: "content",
  schema: z
    .object({
      isPublished: z.boolean().optional(),
      title: z.string().optional(),
      pubDate: z.date().optional(),
      description: z.string().optional(),
      tags: z.array(z.string()),
    })
    .refine(
      (data) => {
        if (data.isPublished) {
          return (
            data.title &&
            data.description &&
            data.pubDate &&
            data.tags &&
            data.tags.length > 0
          );
        }
        return true;
      },
      {
        message:
          "Title, description, pubDate, and tags are required. For published post.",
        path: ["title", "description", "pubDate", "tags"],
      }
    ),
});

Arrr, perfect it be!, In short we are using zod’s refine (custom validation logic) to validate if a post is published, and ignore all validation if not.

At the moment, when writing this post, the frontmatter for this post looks like this:

---
title: How to Automatically Generate Publish Dates for Your Astro Blog
description: >-
  Sail, Ho! Let’s board the ship of automation and plunder the secret to
  automatically generating publish dates for Astro posts, with Yargs and
  Inquirer.js as me trusty first mates!.
tags:
  - astro
  - yargs
  - inquirer.js
---

This checks of the first requirement:

  1. Be able to hide my draft posts in production

So lets tackle requirement number 2!

Automate is more fun

So we could add a isPublished property, check the time and manually update the post, but how fun is that?

My goal is to run a single command, like npm run publish:post, select the post I want to publish, and voilà everything is ready to be staged, commited and pushed.

So to tackle this challenge I need to:

  1. Be able to run a script/cli as npm run publish:post.
  2. Provide me a list of posts in that are not published.
  3. When selecting a post, publish it.
  4. Everything should be easy to use and look good.

Lets get started, to tackle number 1. We will be using yargs so I can make pirate puns build interactive command line tools. To set it up we need to install it:

npm i -D yargs # -D is the shorthand for --dev

I want to install it as a devDependency because I will only use it locally on my machine.

Then we will create a script/post.js in root folder:

#!/usr/bin/env node
import yargs from "yargs/yargs";
import { hideBin } from "yargs/helpers";

yargs(hideBin(process.argv))
  .scriptName("posts")
  .command("publish", "Publish a blog post", () => console.log("publish"))
  .help().argv;

hideBin is a utility from yargs to parse arguments from node process. Then in our package.json we can link the script:

"scripts": {
    ...
    "publish:post": "node ./scripts/posts.js publish"
    ...
},

So if we run npm run publish:post we get:

> blog@0.0.1 publish:post
> node ./scripts/posts.js publish

publish

  1. Be able to run a script/cli as npm run publish:post.
  2. Provide me a list of posts in that are not published.
  3. When selecting a post, publish it.
  4. Everything should be easy to use and look good.

To be able to list posts we need to read from the file system, luckily we can use node’s built in fs for that, but how do we know which posts has the isPublished set to true?

Instead of writing our own frontmatter parser we can use gray-matter to parse and modify the files for us. To install gray-matter:

npm i -D gray-matter

Then our utility function is ready to be implemented:

import path from "path";
import fs from "fs";
import matter from "gray-matter";

const POSTS_DIR = "./src/content/posts";

const listDrafts = () => {
  const files = fs.readdirSync(POSTS_DIR);
  return files
    .map((file) => {
      const filePath = path.join(POSTS_DIR, file);
      const content = fs.readFileSync(filePath, "utf-8");
      const { data } = matter(content);
      return {
        file,
        title: data.title,
        isPublished: data.isPublished,
      };
    })
    .filter((post) => post.isPublished !== true);
};

To add this utility function to yargs we can create a command:

const publishCommand = async () => {
  try {
    const drafts = listDrafts();

    if (drafts.length === 0) {
      console.log("📂 No drafts to publish!");
      return;
    }

    // how to select?
};

yargs(hideBin(process.argv))
  .scriptName("posts")
  .command("publish", "Publish a blog post", publishCommand)
  .help().argv;

  1. Be able to run a script/cli as npm run publish:post.
  2. Provide me a list of posts in that are not published.
  3. When selecting a post, publish it.
  4. Everything should be easy to use and look good.

But how do we select the file we want to publish? Thats where inquirer.js gets handy. Its a collection with common interactive command line user interfaces to simplify user prompts.

To install:

npm install -D @inquirer/prompts

We will focusing on two prompts Select and Confirm .

import { select, confirm } from "@inquirer/prompts";

const drafts = listDrafts();

const selectedPost = await select({
    message: "Select a draft to publish:",
    choices: drafts.map((draft) => ({
        name: `${draft.title || "Untitled Post"} (${draft.file})`,
        value: draft,
    })),
});

const publish = await confirm({ message: "Are you sure?" });

// how to publish?

To be able to modify the selected file we need to create another utility function that uses gray-matter and fs:

import path from "path";
import fs from "fs";
import matter from "gray-matter";

const POSTS_DIR = "./src/content/posts";

const publishPost = (fileName) => {
  const filePath = path.join(POSTS_DIR, fileName);
  const content = fs.readFileSync(filePath, "utf-8");
  const { data, content: markdown } = matter(content);

  data.isPublished = true;
  data.pubDate = new Date();

  const updatedContent = matter.stringify(markdown, data);
  fs.writeFileSync(filePath, updatedContent);
};

Stitching it together with the above prompts:

import { select, confirm } from "@inquirer/prompts";

const drafts = listDrafts();

const selectedPost = await select({
    message: "Select a draft to publish:",
    choices: drafts.map((draft) => ({
        name: `${draft.title || "Untitled Post"} (${draft.file})`,
        value: draft,
    })),
});

const publish = await confirm({ message: "Are you sure?" });

if (publish) {
    publishPost(selectedPost.file);
    console.log(`✅ Successfully published: ${selectedPost.title}`);
} else {
    console.log(`↩️ Aborting...`);
}

So If we now run npm run publish:post:

npm run publish:post

> blog@0.0.1 publish:post
> node ./scripts/posts.js publish

? Select a draft to publish: (Use arrow keys)
 How to Automatically Gen... (generate-publish-dates.md)

Note: that I added the title truncation (…) after finishing this post

Then select the post:

 Select a draft to publish: How to Automatically Gen...
(generate-publish-dates.md)
? Are you sure? (Y/n)

Confirm:

 Are you sure? yes
 Successfully published: How to Automatically Gen...

If we take a look at file, we can see the following updates on the frontmatter

---
title: How to Automatically Generate Publish Dates for Your Astro Blog
description: >-
  Sail, Ho! Let’s board the ship of automation and plunder the secret to
  automatically generating publish dates for Astro posts, with Yargs and
  Inquirer.js as me trusty first mates!.
tags:
  - astro
  - yargs
  - inquirer.js
isPublished: true
pubDate: 2024-11-18T21:59:23.804Z
---

  1. Be able to run a script/cli as npm run publish:post.
  2. Provide me a list of posts in that are not published.
  3. When selecting a post, publish it.
  4. Everything should be easy to use and look good

All requirements done, and I think this solution fulfills (at least for me) the pain of keeping track of updating pubDate when working/publishing new posts.

Final Thoughts

I’m pretty happy with the solution, but as always, there’s room for improvement. Only time and usage will reveal the cracks and imperfections in this solution, though I already have some ideas for improvements in my backlog:

  1. While it may work locally, I still need to manually run some git commands and push the changes. It might make more sense to create a CI/CD pipeline, where each draft post is treated as a job that can be triggered for publishing, and then trigger a rebuilding of the site.
  2. To extend from the above, when a ci/cd is in place, it would be cool to implement scheduling feature. To publish post on specific dates. (can already see a christmas posts, with a lot of puns in it).
  3. To fulfill the above requirement I need to make changes to the script to be both interactive but also argument-driven, leveraging the full potential of using yargs.
  4. Can add chalk to sprinkle some more colors (lovely).
  5. Improve error handling and logging, for example handling user aborting Ctrl+c, or when failing reading/writing to files.
  6. Some new commands like deleteDraft for drafts that are not meant to be, or maybe editPosts to quickly change frontmatter data.
  7. Search and filtering when selecting drafts (if I end up in a pile of unfinished posts).

Now we be finally ashore, adventure’s done, me matey! Hope this treasure o’ knowledge brings ye good fortune. Take care!

Back to posts