JavaScript Mapping Library
As I continue to dig into, and learn, Astro, I thought I’d take a look at creating custom content collections. Content collections are pretty much exactly how they sound – collection of content items you can use within your Astro site. If you go through the excellent Astro tutorial you will find this discussed at the end in the final optional step step. Content collections aren’t required – you can build dynamic sets of data just using file system operations (and that’s how the tutorial has you build the blog) – but they make it easier (imo) to re-use content throughout the site.
I encourage you to check out the docs, but generally content collections come down to three types:
For my demo today, I decided to revisit a post from 2022, "Use Your Saffron Recipes in the Jamstack".
For many years, I made use of Saffron, an elegant web-site/mobile app for recipe management. It supports reading and parsing ugly recipe URLs (which I’ve covered on this blog quite a bit) as well as letting you manage recipes in different cookbooks. It is a damn good site, but I hit the limit of the free tier a few months back and as I don’t really use the import feature much, switched to simply using OneNote instead. That being said, I absolutely think it’s a cool site and worth the $ $ if you want the additional storage above their free tier.
One more reason to like them is that you can, at any point, without wait, get an export of your data. This will give you a zip file of recipes in text file format which look like so:
Title: Soft and Chewy Chocolate Chipless Cookies Description: Source: Sofi | Broma Bakery Original URL: https://bromabakery.com/chocolate-chipless-cookies/ Yield: 16,16 cookies Prep: 15 minutes Cook: 11 minutes Total: 1 hour Cookbook: Deserts Section: Cookies Image: Ingredients: 3/4 cup unsalted butter 1 cup brown sugar, packed 1/4 cup granulated sugar 1 egg + 1 egg yolk, room temperature 1 tablespoon vanilla extract 1 3/4 cup all purpose flour 3/4 teaspoon baking soda 1 teaspoon sea salt + more for sprinkling Instructions: Brown the butter over medium heat, stirring constantly until the butter begins to foam and turns a golden brown, emitting a nutty aroma. Make sure you only brown the butter lightly. When butter browns the liquid evaporates off which can dry out your dough. As soon as the butter starts to turn brown and smell nutty, take it off the heat to prevent any more liquid from escaping. Take butter off the heat and allow to cool. In a large mixing bowl combine the cooled brown butter, brown sugar, and white sugar. Beat until mixed together. Add in the egg, egg yolk, and vanilla extract. Mix well. In separate bowl mix together the flour, salt and baking soda. Mix half the dry ingredients into the wet until everything comes together. Slowly add in the remaining flour a little bit at a time, stopping if the dough starts to get too dry. Refrigerate the cookie dough for at least a half hour, or overnight. When you are ready to bake the cookies, preheat the oven to 350°F and line a cookie sheet with parchment paper. Use a 1 ounce cookie scoop to scoop the cookie dough out into balls, placing them 2 inches apart on the prepared sheet. Bake for 11 minutes*, or until the edges are just golden brown and the centers have puffed up but are still gooey. Allow to cool before eating!
The format follows a pattern of "Key: Value", but with multiple line items being tabbed over from the key defined on the previous line. Back in 2022 for my original post, I wrote a simple function that parsed this data into a basic JavaScript object. Here’s the version I have now (slightly modified from the original):
function parseRecipe(txt:string) { let result:any = {}; let lastKey = ''; let lines = txt.split('\n'); for(let i=0;i<lines.length;i++) { //if the line starts with a tab, its a continuation if(lines[i].indexOf('\t') === 0) { result[lastKey] += lines[i].replace('\t', '') + '\n'; } else { let key = lines[i].split(':')[0]; let rest = lines[i].replace(`$ {key}: `,''); result[key] = rest; lastKey = key; } } // lowercase keys and remove spaces, should i also remove the upper case keys? for(let key of Object.keys(result)) result[key.toLowerCase().replace(/ /g,'')] = result[key]; // special handle for ingredients and instructions to turn into arrays if(result.ingredients) result.ingredients = result.ingredients.split('\n').map((i:string) => i.trim()).filter((i:string) => i.length > 0); if(result.instructions) result.instructions = result.instructions.split('\n').map((i:string) => i.trim()).filter((i:string) => i.length > 0); return result; }
The only real "fancy" part here is how I handle noting the multiline line data by looking for tab characters.
To create my Astro custom content collection, I started by defining src/content.config.ts. This file is where all collections are defined, in my case my demo only has the one:
src/content.config.ts
import { defineCollection } from 'astro:content'; import fs from 'node:fs/promises'; function parseRecipe(txt:string) { let result:any = {}; let lastKey = ''; let lines = txt.split('\n'); for(let i=0;i<lines.length;i++) { //if the line starts with a tab, its a continuation if(lines[i].indexOf('\t') === 0) { result[lastKey] += lines[i].replace('\t', '') + '\n'; } else { let key = lines[i].split(':')[0]; let rest = lines[i].replace(`$ {key}: `,''); result[key] = rest; lastKey = key; } } // lowercase keys and remove spaces, should i also remove the upper case keys? for(let key of Object.keys(result)) result[key.toLowerCase().replace(/ /g,'')] = result[key]; // special handle for ingredients and instructions to turn into arrays if(result.ingredients) result.ingredients = result.ingredients.split('\n').map((i:string) => i.trim()).filter((i:string) => i.length > 0); if(result.instructions) result.instructions = result.instructions.split('\n').map((i:string) => i.trim()).filter((i:string) => i.length > 0); return result; } const recipes = defineCollection({ loader: async () => { /* can't use Astro's glob here because it's doesn't support .txt files */ const files = (await fs.readdir('./recipes')).filter((file) => file.endsWith('.txt')); let r = []; for(const file of files) { let contents = await fs.readFile(`./recipes/$ {file}`, 'utf-8'); let recipe = parseRecipe(contents); r.push({ id: file.replace('.txt',''), slug: file.replace('.txt',''), recipe }); } return r; } }); export const collections = { recipes };
Basically – scan the folder of recipes (where I extracted the zip Saffron exported) and add the information to an array. Per the Astro docs, I ensured I defined an id and slug value to uniquely identify the data.
id
slug
Once defined, it’s rather trivial to use the information. On my index page, I simply list out all of the recipes:
--- import { getCollection } from 'astro:content'; import BaseLayout from '../layouts/BaseLayout.astro'; const allRecipes = await getCollection('recipes'); --- <BaseLayout pageTitle="Recipes"> <div class="recipe-container"> <h1 class="recipe-title" style="font-size: 2.8em; margin-bottom: 40px;">All Recipes</h1> {allRecipes.map((recipe) => ( <div class="recipe-card"> <h2 class="recipe-title" style="font-size: 2em; text-align: left; margin-bottom: 10px;" > <a href={`/recipes/$ {recipe.id}`} style="text-decoration: none; color: inherit;"> {recipe.data.recipe.title} </a> </h2> {recipe.data.recipe.description && ( <p class="recipe-description" style="text-align: left; font-style: normal;"> {recipe.data.recipe.description} </p> )} </div> ))} </div> </BaseLayout>
And then defined a dynamic route for each recipe in src/pages/recipes/[id].astro:
src/pages/recipes/[id].astro
--- import { getCollection } from 'astro:content'; import BaseLayout from '../../layouts/BaseLayout.astro'; export async function getStaticPaths() { const recipes = await getCollection('recipes'); return recipes.map(recipe => ({ params: { id: recipe.id }, props: { recipe }, })); } const { recipe } = Astro.props; const { title, description, source, ingredients, instructions } = recipe.data.recipe; --- <BaseLayout pageTitle=Turning Recipe Data into an Astro Content Collection> <div class="recipe-container"> <div class="recipe-card"> <h1 class="recipe-title">Turning Recipe Data into an Astro Content Collection</h1> {description && <p class="recipe-description">{description}</p>} {source && <p class="recipe-source">From: Raymond Camden</p>} { ingredients && ( <> <h3>Ingredients</h3> <ul class="ingredient-list"> {ingredients.map((item: string) => ( <li>{item}</li> ))} </ul> </> ) } <h3>Instructions</h3> <ol class="instruction-list"> {instructions.map((step: string) => ( <li>{step}</li> ))} </ol> </div> </div> </BaseLayout>
I used Google Gemini to help me define a simple layout and deployed it to Netlify here: https://astro-recipes-demo.netlify.app/. For the most part, it worked well, but one recipe, and it just so happens the first one, https://astro-recipes-demo.netlify.app/recipes/bananabread/, has weird formatting for instructions, but that’s the fault of the source data as it was one of the few that had hard-coded numbers in the instruction text. Ignore that and check out my basic bread with savory stuff recipe instead.
You can find the complete source for this app here: https://github.com/cfjedimaster/astro-tests/tree/main/recipes
As a quick note, I could also make use of the Cookbook and Section values from Saffron if I wanted to make this more complex and depending on how this first week back in 2026 goes, I may do just that. Enjoy!
Photo by Lia on Unsplash
Raymond Camden
You must be logged in to post a comment.
This site uses Akismet to reduce spam. Learn how your comment data is processed.