Steal This Blog! Part 1: Overview, Content Collections, Pages, and Components

Recreating Ghost's Casper theme for Astro, with support for tags, author pages, scheduled posts, tailwind, and more.

This post is part one of the Steal This Blog! Series.
Steal This Blog! Part 1: Overview, Content Collections, Pages, and Components

While the Piano Pronto Blog isn’t primarily a tech blog, I still find it’s a good place for me to share some of the tech we use at Piano Pronto Publishing, Inc. In this article series, I aim to document the codebase that powers this blog, and some of the possibly unique things I’ve done with Astro framework. View the demo.



Table of Contents:


🚀 Introduction: What is Astro?

Astro is the all-in-one web framework designed for speed. Pull your content from anywhere and deploy everywhere, all powered by your favorite UI components and libraries.
https://astro.build/

I haven’t had the need to use an SSG since around 2015, but when we decided to revive the Piano Pronto Blog, I started researching what’s currently popular. Our main ecommerce site is written in Vue, so I thought maybe Vuepress would be a good fit, but when I found Astro I was immediately sold. Since it’s framework agnostic, it meant I could use Vue, if I wanted to, but wasn’t locked-in. Otherwise, its zero-JS architecture meant the blog would be extremely performant.

You can learn more about Astro from their awesome docs. For the rest of this article series, I’ll assume you’re at least somewhat familiar with Astro.


📂 Project Structure

This project is structured similarly to the Official Blog Astro Starter Kit, but more opinionated. If you’re looking for a more bare-bones Astro experience, I’d suggest starting with their blog starter kit. Some of the differences I’ve made in my starter template are:

  • Use the experimental assets feature by default.
  • Remove the layouts folder, instead opting for a single base layout component.
  • Use tailwind with PostCSS and nested declarations.*
  • Include helpful component packages such as astro-headless-ui and astro-icon.

*Note: Maybe it’s because of my long history with SASS, but I prefer to build stylesheets using BEM classes and nested selectors. I still utilize tailwind by using the @apply directive and the occasional inline utility class.

Make sure to modify the src/consts.ts file to set up basic configuration like the site title, default author, and header menu. You can refer to the official documentation for more information of basic structuring of Astro projects.


📚 Content Collections

Introduced in version 2.0.0, content collections are one of Astro’s best features. Utilizing Zod, we can define schemas that are validated at build time, have default values, and can even reference each other!

For this project, we have two collections, blog and authors. Since every blog post has an author, the blog collection has a reference to the authors collection. Although we do set a default author just in case you forget to set an author on any particular blog post. Note: originally I didn’t properly set up the reference between the two schemas, but added it in commit 45d237b.

In addition to Zod’s built-in default() method, I also found it useful to chain a transform() call on the blog schema where I can configure more advanced default values. For example the following code puts draft posts into a drafts tag, and future posts into a scheduled tag.

z
  .object({
    // ...
  })
  .transform((obj) => {
    if (obj.draft == true) {
      obj.tags.push("Drafts");
    } else if (obj.pubDate > new Date()) {
      obj.tags.push("Scheduled");
    }
    return obj;
  }),

There’s a lot more you can do with the transform method. On the Piano Pronto Blog, I allow the heroImage field to be a SKU, and have Astro automatically pull an image from our main ecommerce site.


🔨 Content Helper Functions

Astro has a few built-in functions to query collections, but I had the need to create a few more helper functions. They’re located in the src/content/utils.js file.

I’ve redefined the getCollection function a bit. That’s not to say you can’t use the original one imported from astro:content, but you’ll notice I import my modified function throughout the codebase. Here’s the definition:

export const getCollection = async (type, all = true, reverse = false) => {
  const items = await get(type, (item) => {
    if (all && import.meta.env.MODE == "development") {
      return true; // Show all in dev mode
    }
    if (item.data.draft) {
      return false; // Draft post
    }
    if (item.data.pubDate && item.data.pubDate > new Date()) {
      return false; // Future post
    }
    return true;
  });
  if (type === "blog") {
    items.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
    if (reverse) {
      items.reverse();
    }
    items.sort((a, b) => b.data.featured - a.data.featured);
  }
  return items;
};

The first thing the above code does is let us see both draft posts and scheduled posts (those with a pubDate in the future) while in development mode, but hides them when we build for production. We also have the all parameter, which is used to control where we want to see scheduled and draft posts. Mainly, the homepage won’t show them even on development mode, because we want to see how the homepage will look in production.

The second thing the above code does is sort our blog posts chronologically, but with featured posts at the top. This is easier than sorting the posts separately in every page component, unless we need a different order.

We also have a getTaxonomy function in this file, which will reduce all items of a collection to an array of unique field values and the number of times they occur. For example, if we call getTaxonomy("blog", "tags"), we’ll get an array such as [{ value: 'Popular', count: 3 }, { value: 'Guides', count: 2 }]. It works for any field in the schema that’s an array or reference type, so we can use the same function to get all authors.

The last notable feature here is the getCollectionFilter function, which lets us filter a collection by some field value. It works in conjunction with getTaxonomy, so we can call getCollectionFilter("blog", "tag", "Popular") to get all posts tagged popular.


📄 Pages

Like many other SSG frameworks, Astro uses file-based routing. This makes it a breeze to group common pages into a single dynamic route minimizing code.

Here’s some of the main page components I’ve used in this blog:

  • [...page].astro — Renders the homepage including pagination. It’s pretty straightforward, mainly just calling the BaseLayout and PostGrid components.
  • [slug].astro — Renders all posts from the blog collection by including the PostPage component. We also pass the entire blog entry to the BaseLayout component for meta generation.
  • [term][value][...page].astro — Renders our filter by pages, for example example.com/tags/popular/ or example.com/author/houston/.
  • tags.astro — Renders the tag index page, where users can navigate the blog by browsing through a list of all tags.

This section will remain brief for now, since I will be expanding on these page components in subsequent articles about meta tags, post filtering, and searching.


🧩 Components

Just like all other frontend frameworks, Astro utilizes components. However, one major difference with Astro is that components render to static HTML at build time or during the SSR process (which we aren’t using for this blog.)

Astro components are really nice to work with, allowing top level await, props, named slots, JSX-like syntax, and more. Of course since Astro is framework agnostic, we can use framework components from other frameworks like Vue.

Here’s an overview of the components in this starter theme:

Layout Components

As mentioned previously, I opted to not use the src/layouts/ directory, but instead define a single base layout component. This makes sense to me as there aren’t a lot of drastically different pages on this blog. Instead, I set up some props and slots to make minor adjustments. Here’s an example:

---
import BaseLayout from "@/components/layout/Base.astro";
const page = {
  data: {
    title: "My custom page",
    description: "Meta description",
    image: import('../assets/my-image.png'),
  },
};
---
<BaseLayout layout="wide" page={page}>
  Main content goes here
  <div slot="end">
    Content to appear before the footer, you may want to set a max width.
  </div>
</BaseLayout>

First we can pass either layout="wide" or layout="narrow". Next, we should pass it a page object containing at least a title and description value, and notice that we have these values under a data key (that’s so we can also pass an entry from a content collection.) Finally, we have an optional slot="end" slot, which we use for showing similar articles on the post page.

If you head over to src/components/Base.astro, you’ll see it’s pretty simple. One interesting thing to note, is that we have a seemingly useless comment {/* layout-wide layout-narrow */}. This is actually so that tailwind’s compiler knows that these are the possible classes that can be used, and won’t omit them from the bundled CSS.

We’ll go over Meta.astro later, and Footer.astro isn’t that interesting, so let’s look at Header.astro:

We use the excellent astro-headless-ui library for creating the main navigation. We import the menu links from src/consts.ts, but also check to see if we’re in dev mode, and if so, add some extra menu items. This makes it really easy to see what blog posts you’re working on, and what blog posts you have scheduled.

The next interesting feature is that we are using the assets feature to include the main logo. This is useful because the PNG in our assets folder is 744 x 100 pixels, but since we have height="30", Astro will resize and convert it to webp automatically.

Finally, we include the only client-side script in this blog template at the time of this post. It’s an event listener to open or close the hamburger menu that simply toggles a class on the header element. We’ll use CSS to then show or hide the dropdown menu.

Post Components

These components are responsible for rendering just about everything related to blog articles. There are a few main entry points:

  • Block.astro — Renders summary blocks. We can set a horizontal prop to make a larger summary block like on the homepage, but the HTML structure is the same regardless.
  • Grid.astro — Renders multiple summary blocks into a grid. We can set a layout="featured" prop to make the first few posts larger like on the homepage. Notice again how we have a comment containing the possible class names so tailwind doesn’t strip them from the compiled CSS.
  • Page.astro — Renders the individual blog post page. There’s nothing too special about this file, we simply render tags, the byline, image, and use the @tailwindcss/typography plugin for the post body.

There are some other helper components for reused HTML in both Block.astro and Page.astro, such as:

  • Image.astro — Render a post image, or the placeholder, along with the post title as the alt tag.
  • Attributes.astro — Render the publish date and reading time in a <ul> tag, since we use this in both the summary and main page components.

🤓 Conclusion and Next Steps

Hopefully this article helps to explain some of the overall design choices I’ve made when making this starter template, and helps you decide whether to use “Steal This Blog!” as your starter template, or to start with a more bare-bones approach. There are features I’ve already implemented in the Piano Pronto Blog that I plan to implement into this template, and I plan to write more specific articles on some of these features.

Future articles will cover (in no particular order):

  1. Filtering posts by tag, author, and more. Check out Part 2
  2. Calculating reading time. Check out Part 3
  3. Implementing a search box with orama.
  4. Creating a custom integration to generate meta images with satori.
  5. Creating a custom integration to minify HTML.
  6. Creating custom MDX components.
  7. Creating a comment system from scratch.
  8. Using nginx to create server side dark mode.


Justin Beaty
Written by Justin Beaty
As the sole developer at Piano Pronto, Justin is a master of bringing life to our website and ensuring it's the best possible experience of shopping for sheet music online. In a world of algorithms and APIs, Justin is our digital maestro, orchestrating every line of code to perfection. He's a transplant to Southern California from the East Coast, and never complains about the weather.

Comments

User's avatar


No comments yet
Be the first to comment.

Steal This Blog! Part 3: Partial Components and Reusable UI
Nerd Corner

Steal This Blog! Part 3: Partial Components and Reusable UI

An overview of the seven most used components from this theme, with full source code of each component, and discussion of their most interesting features.

  • 11 min read
Read more
Steal This Blog! Part 2: Filtering Collections by Tags, Authors, and More
Nerd Corner

Steal This Blog! Part 2: Filtering Collections by Tags, Authors, and More

Creating a reusable way to filter blog posts and other content collections by various fields in Astro with pagination and meta tags.

  • 17 min read
Read more
Sam Phillips: "If I Could Write"
Pop Sheet Music
By Request
2000s Sheet Music

Sam Phillips: "If I Could Write"

Explore piano solo arrangements by Jennifer Eklund of the short and sweet tune "If I Could Write" by Sam Phillips.

  • 5 min read
Read more