Enabling open working on Piccalilli part one: content collections

I said we’d work in the open and I mean it. This content is on my blog for now because I’m literally building the infrastructure on Piccalilli as we speak.

This little mini series on my blog covers me doing the Open Working project and the first part of that is setting up Astro to support the content that we will publish.


Let’s have a look at what’s happened prior to this point:

  1. We’ve done some internal planning and strategy
  2. I’ve populated our backlog with some stuff that needs doing to enable us to Post™
  3. We’ve published an article that serves both to let people know what we’re up to and also, form a priority guide of sorts for the Projects landing page on Piccalilli

Screenshot of a Notion document outlining a 'Project' content type. It includes a table listing required properties like Title, Start date, End date, Summary, Social Image, Feature Image, Current sprint, and ID. Below, a 'Notes' section provides guidance on image usage and linking. A 'Project Post' section follows with further schema notes.

This clip shows the ticket I’m working from. The aim is:

  1. Define some configuration of the content types
  2. Refactor the existing configuration to create a sharedConfig
  3. Make adjustments to the existing layout we use to render articles
  4. Build out a shell for /projects and /projects/${slug} to better inform Jason, our designer

Today’s task

I’ve been working through points 1 and 2 from the ticket.

First up, I’m creating a coreConfig. This is what our Astro content collections config looked like before:

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

const blogCollection = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    pubDate: z.date(),
    summary: z.string().optional(),
    author: z.string().optional(),
    image: z
      .object({
        url: z.string(),
        alt: z.string(),
      })
      .optional(),
    tags: z.array(z.string()),
    link: z.string().optional(),
    socialSummary: z.string().optional(),
    socialImage: z.string().optional(),
    series: z
      .object({
        name: z.string(),
        order: z.number(),
      })
      .optional(),
    surpressAuthorPromo: z.boolean().optional(),
  }),
});

const linksCollection = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    pubDate: z.date(),
    summary: z.string().optional(),
    author: z.string().optional(),
    image: z
      .object({
        url: z.string(),
        alt: z.string(),
      })
      .optional(),
    link: z.string().optional(),
    socialSummary: z.string().optional(),
    socialImage: z.string().optional(),
  }),
});

const theIndexCollection = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    pubDate: z.date(),
    summary: z.string().optional(),
    author: z.string().optional(),
    image: z
      .object({
        url: z.string(),
        alt: z.string(),
      })
      .optional(),
    socialSummary: z.string().optional(),
    socialImage: z.string().optional(),
    sponsorMessage: z.string().optional(),
    sponsorCTAURL: z.string().optional(),
    sponsorCTALabel: z.string().optional(),
  }),
});

const authorCollection = defineCollection({
  type: 'content',
  schema: z.object({
    name: z.string(),
    pubDate: z.date(),
    summary: z.string().optional(),
    socialSummary: z.string().optional(),
    company: z.string().optional(),
    language: z.string().optional(),
    links: z
      .array(
        z.object({
          name: z.string(),
          url: z.string(),
        })
      )
      .optional(),
    avatar: z
      .object({
        full: z.string(),
        scaled: z.string(),
      })
      .optional(),
    promo: z
      .object({
        heading: z.string(),
        summary: z.string(),
        buttonLabel: z.string(),
        buttonURL: z.string(),
      })
      .optional(),
  }),
});

const completeCssCollection = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    pubDate: z.date(),
    author: z.string().optional(),
    course: z.string().optional(),
    module: z.string().optional(),
    lessonNumber: z.number().optional(),
  }),
});

const emailsCollection = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    heading: z.string(),
    meta: z.string().optional(),
    pubDate: z.date().optional(),
    headingSize: z.enum(['extra-large', 'large']).optional(),
    promoBox: z
      .object({
        heading: z.string().optional(),
        content: z.string(),
        buttonLink: z.string().optional(),
        buttonLabel: z.string().optional(),
        hideAdvertiseLink: z.boolean().optional(),
      })
      .optional(),
    showUnsubscribeLink: z.boolean().optional(),
    closerContent: z.string().optional(),
    stream: z.string().optional(),
  }),
});

export const collections = {
  blog: blogCollection,
  links: linksCollection,
  'the-index': theIndexCollection,
  author: authorCollection,
  'complete-css': completeCssCollection,
  emails: emailsCollection,
};

I’ll not go into the detail of how this stuff works because Astro do a good job of that already, but in short, each object defines a data structure for each type of content on Piccalilli.

The last block — export const collections — sets the directory that contains .md and .mdx files and its value (the right side) is an assignment to a content collection configuration.

There’s a lot of repetition going on! Before I add more, I thought it would be a good idea to identify a shared set of configuration options that are used, mainly for blogCollection, linksCollection and theIndexCollection.

const coreConfig = {
  title: z.string(),
  pubDate: z.date(),
  summary: z.string().optional(),
  socialSummary: z.string().optional(),
  socialImage: z.string().optional(),
  author: z.string().optional(),
}

Now I can spread coreConfig in blogCollection, linksCollection and theIndexCollection. I can also spread it in the new collections.

This is a TypeScript project (don’t come at me), so a good idea is to build the site to make sure I haven’t broken anything.

Screenshot of a TypeScript error in a code editor. The error message states that the property 'link' does not exist on the type definition for a blog post's data object. The highlighted line attempts to access 'post.data.link' to generate an author link, causing a type mismatch in src/pages/index.astro.

Ah, as is tradition, I broke the build. It seems like we’ve got some loose querying going on, on the homepage.

const authorLink = post.data.link ? post.data.link : `/author/${generateSlug(authorName)}`;

return {
  id: post.id,
  url: `/${post.data.link ? 'links' : 'blog'}/${post.slug}`,
  title: post.data.title,
  summary: post.data.summary,
  author: { name: authorName, link: authorLink },
  date: post.data.pubDate,
  tag: post.data.tags ? { label: post.data.tags[0], link: `/category/${generateSlug(post.data.tags[0])}` } : undefined,
  directLink: post.data.link,
};

In that snippet, we’re generating props for a <PostList /> component. If it is a links post, we assign the canonical URL we are linking to as the author’s link because that author won’t exist in our system. For a normal post, we link to the authors page, like mine, for example.

Now, in these situations the temptation is to fix everything and let me tell ya, I felt the urge, but I also asked Leanne to give this little PR a review, so the last thing I want to be doing is adding more to that — especially introducing changes on something as important as the homepage!

It’s also a good idea to stay focused on the task at hand, so with that in mind, I made this decision:

  1. I’ll bring link into the sharedConfig temporarily
  2. I’ll raise a ticket in our refactoring backlog to clean up the querying, remove link from sharedConfig and put it back on linksCollection
// Shared core configuration for article-like content
const coreConfig = {
  title: z.string(),
  pubDate: z.date(),
  summary: z.string().optional(),
  socialSummary: z.string().optional(),
  socialImage: z.string().optional(),
  author: z.string().optional(),

  // This property is in the shared config because we use it to query between Blog and Links in areas such as the homepage popular feeds
  link: z.string().optional(),
};

Folks, always leave comments if you can. They’re useful for not just explaining code, but also, leaving a decision trail to give other developers context. I talk about that at length in Complete CSS as part of the important core skills that will improve you as a developer.

Adding the new collections

With the coreConfig set up and the project building again, we’re safe to proceed. Let’s start with the overarching Project structure first:

// The core pillar for each open working project which itself, has a page
const projectsCollection = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    startDate: z.date(),
    endDate: z.date(),
    summary: z.string(),
    socialImage: z.string(), // For each post to inherit and itself when shared on social media
    featureImage: z.string(), // For rendering on the front-end
    currentSprint: z.string(), // E.G. Sprint 1: Discovery
    ID: z.string(), // So project posts can anchor to a project
  }),
});

There’s absolutely nothing optional here because this is — like my comment suggests — a content pillar. It’s a structure for posts to anchor themselves too. When a reader goes to /projects or /projects/${slug} this information will be surfaced. We can also surface it in project posts if we like.

Speaking of which, let’s define our project posts structure, utilising our fresh coreConfig:

// Each of these belongs in a project ☝️
const projectPostsCollection = defineCollection({
  type: 'content',
  schema: z.object({
    ...coreConfig,
    projectID: z.string(), // To reference a Project's ID field
    discipline: z.enum(['Strategy', 'Design', 'Development', 'Quality Assurance', 'Client Management']),
    videoURL: z.string().optional(),
    sprint: z.string(),
  }),
});

Project posts are almost identical to normal blog posts, but with a few extra fields like projectID, discipline, videoURL and sprint.

I was going to make a projectSprints a structure too for both projectPostsCollection and projectsCollection but it felt like I was creating busy work for us by over-abstracting. It’ll be totally acceptable to group posts by sprint, just like we do with module on completeCssCollection earlier in this post. As I see it, it’s a simpler, flatter approach, so it wins.

With these structures in place, all we have to do is add them to our collections object:

export const collections = {
  blog: blogCollection,
  links: linksCollection,
  'the-index': theIndexCollection,
  author: authorCollection,
  'complete-css': completeCssCollection,
  emails: emailsCollection,
  projects: projectsCollection,
  'project-posts': projectPostsCollection,
};

All projects content will be in our projects directory as mdx files and project posts will be in project-posts as mdx files. I’m still slightly unsure about this organisation strategy because I want the URLS for project posts to be projects/${postSlug}, but I’ll cross that bridge in the next part. I’ve not added any content yet, so it’ll be no biggie to edit the collections object, accordingly.

For now, we’re done so I’ll see you in the next one.


Open working is only possible thanks to the generous people that support Piccalilli on Open Collective. Please consider supporting us!


👋 Hello, I’m Andy and this is my little home on the web.

I’m the founder of Set Studio, a creative agency that specialises in building stunning websites that work for everyone and Piccalilli, a publication that will level you up as a front-end developer.

I’ve also got a CSS course called Complete CSS to help you get to a level in development that you never thought would be possible.


Back to blog