title | description | pubDate | heroImage | keywords | |||||||
---|---|---|---|---|---|---|---|---|---|---|---|
How to GENERATE markdown files in ASTRO |
You know CMSs are a thing, right? |
1 Dec 2023 |
../../assets/blog/generating-astro-files.png |
|
If you already used Astro and/or Contentful and are familiar with them, you can skip to The Connection section and grab the code from there. :)
Astro is one of the most loved, versatile, and promising JS frameworks of the 2020s. It allows you to create a static website from the ground up, using syntax very similar to vanilla HTML. On top of that, if you want to add a little bit of spice, Astro allows you to use most UI libraries on the market like React, Vue, and Svelte. Integrations are also a thing, giving you the power to use TailwindCSS, Sitemaps, icons, storybooks, MDX, and many more with just one line in the terminal. With its newest updates, Astro also added support for Server-Side Generation or Hybrid mode, where you can have static pages and server-side ones at the same time! Take that, Next.js.
But now, in all seriousness, Astro really, really shines when it's used to create SSG sites like blogs or the site you are reading this article on. But when the piles of content get too big, people tend to use a CMS system, like Contentful, to store their data and stream it to their platforms. And while Astro is very good at generating content and optimizing images from its .md
or .mdx
files, I have not found an official way in their documentation to generate such files in the project's file system.
So with little to no research on my side if there are any libraries that can do this, I can proudly say that this is the only way you can do it. (That's how I did it in a real project.) And we will utilize Node.js scripts and Contentful CMS to do that magic.
Let's begin!
- π Youtube: Comming soon
- π Github:
First things first, we need an Astro project, right? So let's make one with this command. Make sure you have the latest version of Node.js installed and make sure you use NVM, bacause why wouldn't you?!
# create a new project with npm
npm create astro@latest
The Installation guide has a full step-by-step tutorial on how to setup your project with a cute little mascot helping you on the way :3.
It will ask you where you want to create your project, so just choose your destination and name your project. Since I have already opened the folder I want to install my project into in VS Code, I will just type ./
astro Launch sequence initiated.
dir Where should we create your new project?
./ # <-- type this if you have your project opened in vs code or the terminal
After that I am going to choose the blog template, since it already has a Content Collection created. Since you are reading this, you most probably know what a content collection is, but briefly it's just one ts file, that defines the collection with ZOD and some folders with .mdx
files, which will generate our pages. More on that later. For now just choose the second option.
tmpl How would you like to start your new project?
β Include sample files
β Use blog template # <-- choose this one
β Empty
Next prompt will ask you if you'd like to install your deps - choose yes. This will create a node_modules
folder in your project.
After that is the prompt for TypeScript. I prefer to use it, so I will choose yes
and then strict
, but you can choose what ever suits you best.
Last step - it is going to ask for a git repo, which I won't setup in this tutorial.
And finally we are greeted with this super cute screen, which means we are ready to go!
After our project init we can jump to our content folder, where we can see the config file, a blog folder and 5 generated markdown posts. These .md
and .mdx
files are the ones that we need to generate from our CMS
Let's start with the .md
or .mdx
files. They look a lot like an ordinary .astro
file, they should start with the triple dashes (---)
, then between the first and the secont dashes you have your key/value area, where you can define constants. This is called the frontmatter. After the second tripple dashes is the content area, where you can write content in Markdown sythax. The difference between .md
and .mdx
is that .mdx
files can also import components in the content section.
---
# this is the frontmatter, it uses the yml sythax.
title: "First post" # <-- these are constants
description: "Lorem ipsum dolor sit amet"
pubDate: "Jul 08 2022"
heroImage: "/blog-placeholder-3.jpg"
keywords: # <-- this is an array
- word1
- word2
nested-object: # <-- this is an object
property: 'value'
js-array: ['value1', true, { key:"value" }] # <-- in .mdx you can write them like this aswell. It would still count, just do NOT forget the space between the key: and the "value".
invalid-value:"dont do this"
tip: "also, don't use commas", #<-- this will result in an error.
---
{/* this is the content section */}
# Heading1
## Heading2
### Heading3
#### Heading4
- ul item
1. ol item
[link](https://something.com)
![image](/image)
paragraph
import Title from './../title.astro'
<Title>I am a component</Title>
> > > > > > > > > >
Now let's look at the config. It allows us to create and validate new collections using Zod. I won't go into details on how Zod works, but I will explain how you can modify or create collections. first we must import defineCollection
and z
(zod) from "astro:content"
. If you see some red error squiggly lines, do not panic. We haven't generated our types and collections yet! We can run npm run build
to fix that. Now back to the config file.
We have this piece of code:
import { defineCollection, z, reference } from 'astro:content'
// we start by asigning a constant the return of the defineCollection function
const blog = defineCollection({
// we pass an object with one key: schema
// The schema validates if the properties in the frontmatter are the right type, length, format and so on
// if they are not, a build error with be thrown
schema: ({ image }) =>
z.object({
// z.object is like an object, just in zod :P
title: z.string(), //<-- this is a string
description: z.string(),
pubDate: z.coerce.date(), //<-- this turns human readable date into a new Date() class
updatedDate: z.coerce.date().optional(), //<-- this is optional property
heroImage: image().optional(), // this is the image helper, it autoimports images from your file system and generates optimized ones
heroImageString: z.string().optional(), // <-- you can pass images as paths too, but they won't be optimized if you don't pass width and height too.
heroImageObject: z // <-- This is what we are going to use. It imitates the ImageMetadata that astro:image needs
.object({
src: z.string(),
width: z.number(),
height: z.number(),
alt: z.string(),
format: z.string(),
}),
keywords: z.string().array(), // <-- this is an array
someOtherCollectionEntry: reference('collection-name'), // <-- this is like a relation, you have to pass the slug (folder and file name) of the file you want to reference and it will return its slug and collection name
}),
})
export const collections = { blog } // <-- we export all our schemas, that way we can then get them with the getCollection function
Do we know what the schemas and markdown files are now? We do? Good. Let's continue now.
We know what data we should generate and we know how our markdown file should look like.
Our schema has five fields:
- title : string
- description : string
- pubDate : string
- updatedDate : string?
- heroImage : object
and one hidden content field that is a markdown string
const blog = defineCollection({
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: z.object({
src: z.string(),
width: z.number(),
height: z.number(),
alt: z.string(),
format: z.string(),
}),
}),
})
That means our generated .mdx
files should look like this:
---
title: 'Lorem ipsum'
description: 'Lorem ipsum dolor sit amet'
pubDate: 'Dec 1 2023'
heroImage:
src: 'some-url'
width: 450
height: 450
alt: '1.jpeg'
format: 'jpeg'
---
Lorem ipsum
Simple, right? Now we must step away from the code and setup our CMS. As I typed up there, we are goind to use Contentful, because it's pretty easy to setup.
Now we have to go to Contentful. Since this is not a Contentful tutorial, I won't go over how to create and account and space. We will start with content type creation. Let's create our first content type - the blog. We will add the same fields that we have in our schema, same types and requirements + two additional fields - the slug and the content.
You should setup your fields as follows:
- title:
text -> short text -> required, entry title
- slug:
text -> short text -> required, slug input, unique
- description:
text -> long text -> required, long text input
(we don't want MD here) - pubDate:
date -> required, date and time
- updateDate:
date -> date and time
- heroImage:
media -> required, accept only Image
- content:
text -> long text -> required, markdown input
After creating our content type, it should look like this
Some info for the additional fields.
- The slug
- That is the field that will generate our ULRs. It's typically infered from the entry title which is our, you guessed it, title field.
- The content
- It is a long text field that supports markdown synthax. Woah. Just like Astro's
.mdx
files π±. Wonder why we choose Contentful...
- It is a long text field that supports markdown synthax. Woah. Just like Astro's
After we've done the types, it's time to create our first post.
Alright! We have our CMS set up and ready, and we also have our frontend schemas ready to consume it. We just need to connect the two of them.
Let's start by obtaining our space id and API key from Contentful:
If you do not have one:
Settings Dropdown -> Api keys -> Add API key -> Input a name and description -> See your Space ID and Content Delivery API - access token
If you do:
Settings Dropdown -> Api keys -> Find and click the existing key -> See your Space ID and Content Delivery API - access token
Now that we have our keys, we can go back to our project and put them in an .env
file!
.env
CONTENTFUL-SPACE-ID={YOUR_SPACE_ID}
CONTENTFUL-API-KEY={YOUR_API_KEY}
Tip: make sure this file is added in .gitignore
After we have our keys, we must install the contentful package from npm
npm i contentful
And create a file contentful-client.mts
import contentful from 'contentful'
const client = contentful.createClient({
space: import.meta.env['CONTENTFUL-SPACE-ID'],
accessToken: import.meta.env['CONTENTFUL-API-KEY'],
environment: 'master',
retryLimit: 3,
})
export default client
You can create it whereever you like, I am going to create a utils
folder in my src
directory and add the client there.
./src/utils/contentful-client.mts
We also have to install a package called dotenv
, so that we can load our env before excecuting any node scripts.
And we are ready to beggin with making the scripts. All we have to do is create a few functions, 3 to be exact:
- a file saver helper function that accepts the path, the name of the file and its content and creates or updates a file in that specific directory
- a generate function, that gets content from contentfull and writes it down as json file
- a writer function that maps the contentful file to match our schema and then calls the file saver function to save that file/files.
Ok, let's begin by creating our helper function. First we have to import file system
or fs
so that we can read and write to the file system. After that we are going to create a function with 4 parameters:
- the name of the operation (for loggin purpouses)
- the file name - a string
- the path to the file, including the file name with its extension
- the file content as string
import * as fs from 'fs/promises'
export default async function fileSaver(
service_name: string,
filename: string,
path: string,
file: string
) {
// todo: creates ot updates files
}
Once we have those we can try to create or update our file. First we need to access the file. If we succeed, we will overwrite it. If we cannot access it, that means that there is no such file, so we can create it. We will use file system methods to do so.
import * as fs from 'fs/promises'
export default async function fileSaver(
service_name: string,
filename: string,
path: string,
file: string
) {
try {
//
await fs.access(path)
} catch (e) {
await fs.mkdir(path)
}
}
There. If access does not throw an error, that means that there is a file with that name. If it does, that means that there isn't any. (Since we are accesing files in a non private directory, there shouldn't be any permission errors, so the only one is the once where the file does not exist.)
Now we have to do the writing. We can call fs.writeFile
which requires a few params like the encoding, a flag and a signal to abor the request if needed. Let's make those.
import * as fs from 'fs/promises'
export default async function fileSaver(
service_name: string,
filename: string,
path: string,
file: string
) {
console.log(
`${new Date().toLocaleTimeString()} [${service_name}]: Trying to generate file ${filename} at ${path}`
)
const controller = new AbortController() // <- you can call controller.abort() to stop the request any time
const { signal } = controller // <-- extract the signal, so you can pass it to the writeFile function
const settings = {
encoding: 'utf8' as BufferEncoding, // <-- set the encoding as UTF8
flag: 'w', // <-- !set the flag to w. That means that it will write the file if it does not exist, or will overwrite it if it exists. more on flags: https://stackoverflow.com/a/50174822/16430955
signal, // <-- pass the signal
}
try {
// check if the directory it exists
await fs.access(path)
// write the file, needs path + file + extension
await fs.writeFile(path + '/' + filename, file, settings)
} catch (e) {
// if not, make it
await fs.mkdir(path)
// write the file, needs path + file + extension
await fs.writeFile(path + '/' + filename, file, settings)
} finally {
// finaly log the operation
console.log(
`${new Date().toLocaleTimeString()} [${service_name}]: β
${filename} was generated at path ${path}`
)
}
}
And here is the whole function without any comments so you can assimilate it better:
import * as fs from 'fs/promises'
export default async function fileSaver(
service_name: string,
filename: string,
path: string,
file: string
) {
console.log(
`${new Date().toLocaleTimeString()} [${service_name}]: Trying to generate file ${filename} at ${path}`
)
const controller = new AbortController()
const { signal } = controller
const settings = {
encoding: 'utf8' as BufferEncoding,
flag: 'w',
signal,
}
try {
await fs.access(path)
await fs.writeFile(path + '/' + filename, file, settings)
} catch (e) {
await fs.mkdir(path)
await fs.writeFile(path + '/' + filename, file, settings)
} finally {
console.log(
`${new Date().toLocaleTimeString()} [${service_name}]: β
${filename} was generated at path ${path}`
)
}
}
Ok, now that we have our helper function we can write the generate
one. It will simply call contentful and get the entries we specified, then use the helper function to create a JSON file.
Let's create a folder in src
directory called code-gen
, then inside we will make one named blog
for our blog collection and in it we will create the generate.mts
file.
import client from '../../utils/contentful-client.mjs'
import fileSaver from '../../utils/file_saver.mjs'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const filename = 'content.json'
const path = __dirname
export default async function generate() {
console.log(`[codegen]: π Fetching data for ${__dirname}...`)
try {
const entries = await client.getEntries({
content_type: 'blog',
limit: 1000,
})
const content = JSON.stringify(entries, null, 2)
const file = `${content}`
fileSaver(`codegen-blog`, filename, path, file)
} catch (e) {
console.error(`[codegen-blog]: β generating json at ${__dirname} FAILED!`)
console.error(e)
}
}
generate()
The function may look scary, but it is actually pretty straight forward. We first import our contentful client
and file saver
functions. We then get dirname
and join
from the path
library, which you do not need to install. And we will also need fileURLToPath
from url (no installing as well). We will then do the following:
- we will get the current file's path from
import.meta.url
with the functionfileURLToPath
. It decodes the file URL to a path string and ensures that the URL control characters (/, %) are correctly appended/adjusted when converting the given file URL into a path. Resource - we will then get the dirname by calling
dirname
with__filename
. Since this is a node.js env and not browser, we do not have access to dirname in the global scope. - then we will create our
filename
, which will becontent.json
- after that we will create the path to our file with
join
frompath
which basically creates an absolute path for the current file system to the desired location. You can use relative paths as well, which we will do when generating files in thecontent
folder.
After we have created those consts, we can proceed with the http request to contentful. We will use our client to get all entries.
// Tip: You can get all locales with:
// client.withAllLocales.getEntries(...)
const entries = await client.getEntries({
content_type: 'blog', // blog collection
limit: 1000, // get the max amount of entries
})
Once we have our entries we can create a JSON string from them
const content = JSON.stringify(entries, null, 2)
// creating a new const here to show you that you can add other text. It's just plain text file.
const file = `${content}`
fileSaver(`codegen-blog`, filename, path, file)
This function will now create a new file called content.json
next to our generate.mts
file.
We have one last function to create and that is write.mts
. The function gets content
from the json and then maps all entries to match our collection schema. It also can insert default values. After it is ready, it writes it down to file. Since we covered that already, let's see that function.
import fileSaver from '../../utils/file_saver.mjs'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
import api_content from './content.json' assert { type: 'json' }
import generateImageFromContentful from '../../utils/generate-image-from-contentful.mjs'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
import fsExtra from 'fs-extra'
export default async function write() {
console.log(
`[codegen]: β‘ Purging all files from directories src/content/blog...`
)
await fsExtra.emptyDir(join(__dirname, `../../content/blog`))
console.log(`[codegen]: βοΈ Writing data for ${__dirname}...`)
try {
api_content.items.forEach((item) => {
const filename = `${item.fields.slug}.mdx`
const path = join(__dirname, `../../content/blog`)
const content = {
title: item.fields.title,
description: item.fields.description,
pubDate: item.fields.pubDate,
heroImage: generateImageFromContentful(
item.fields.heroImage.fields.file,
'original'
),
}
const file = `---
${Object.entries(content)
?.filter(([key, value]) => !!value)
?.map(([key, value]) => `${key}: ${value}`)
.join('\n')}
---
${item.fields.content}
`
fileSaver(`codegen-write-${filename}`, filename, path, file)
})
} catch (e) {
console.error(`[codegen-write]: β writing data for ${__dirname} FAILED!`)
console.error(e)
}
}
write()
Summing up the function, we do the same as before. We get the dirname, we create the file name, we create content for the file, that means mapping the fileds of content.json
. generateImageFromContentful
does the same, it gets the image from contentful and maps its fields to fit our schema.
We then create a text file content const file
that has the tripple dashes (---), the front matter (all properties). And after the second dashes we put our content. We then save the file with fileSaver
.
And that is it. That are all the files you need to fetch data and write it down to the file system. Now we just need to create the node scripts, so that we can call our functions before the build.
We need three scripts. One for the generation, one for the writing and one to call them before the build starts.
Let strart with the generate one:
"generate:blog": "npx ts-node -r dotenv/config --experimental-specifier-resolution=node --esm ./src/code-gen/blog/generate.mts",
Let's see what we have here.
- We call
npx ts-node
to execute typescript files. If you use js, you can simply callnode
, - We then load our
.env
file with-r dotenv/config
. - We pass a flag
--experimental-specifier-resolution=node
to accept file extensions. - We pass a flag
--esm
to say we use ESModules (.mts files) - We give it the path to our file
./src/code-gen/blog/generate.mts
And this script will call our function in the generate file.
Next we need one for the write file, which will be pretty much the same:
"generate:blog": "npx ts-node -r dotenv/config --experimental-specifier-resolution=node --esm ./src/code-gen/blog/write.mts"
And after that we need a prebuild script which will call both scripts one after another:
prebuild: npm run generate:blog && npm run write:blog
You can optimize the build script to call another function, that imports all other functions and executes them for a very big performace boost, but for now we will leave it as is.
And that IS it. There is nothing more to do. You can stack these files and make more scripts (or import them in prebuild func) to generate more files at build time.
- Q: I have errors in the terminal about tsconfig
- Here, use this one
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noEmit": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"skipLibCheck": true,
"allowJs": true,
"strict": true
}
}
or just copy astro's one from node modules. Or use custom one.
- Which version of node is that?
- v18.17.0
- I have type errors.
- Pres ctrl+p / command + p and type
> Developer: Reload Window
, sometimes Astro gets stuck on types. Or trynpm run astro sync
- I still have them.
- try to rebuild your project to get the new content.
- Still have them...
- Go to the github repo and copy the files from there. Sometimes you can make a mistake so unnoticeable that even god won't be able to find it.
- My image files are blowing up with errors.
- You have to change every
<img/>
tag with<Image>
fromastro:assets
and do this
<Image src={your_src as ImageMetadata} alt="some text" />
- Astro said that it can't find an image?
- If you have relative imports somewhere, where you use astro:image, you have to import the image yourself and pass it
Instead of
<Image
src={{
src:"/image.png",
width: 100,
height: 100,
alt: "asd",
format: "png",
...
}}
/>
Do this:
import image from '../path/to/your/image.png'
<Image src={image} alt="asd">
Thanks for reading, guys! Hope i helped!
See you in the next one!