Blog posts
Blogatto discovers blog posts from Markdown or Djot files with YAML frontmatter. This guide covers the supported source formats, directory convention, frontmatter fields, multilingual support, and post assets.
Source formats
Blogatto recognizes the following extensions when discovering post source files:
Both formats use the same YAML frontmatter block, the same Components for HTML output, and the same Post(msg) value at the end of the pipeline. Mix-and-match freely within a single blog: each post directory chooses its own format.
Note: Markdown parsing options (
Optionsrecord) only affect.mdfiles. Djot parsing follows the djot specification and ignores those options.
Directory-per-post convention
Each blog post lives in its own directory. The directory may contain index.md, index.dj, or index.djot (plus per-language variants):
blog/
my-first-post/
index.md # Default language (Markdown)
index-it.md # Italian variant
index-fr.md # French variant
cover.jpg # Asset copied to output
djot-post/
index.djot # Default language (Djot)
diagram.png
Blogatto searches all directories listed in post.path() recursively.
Frontmatter
Each markdown file must start with a YAML frontmatter block:
---
title: My First Post
date: 2025-01-15 00:00:00
description: A short description of the post
featured_image: /images/hero.jpg
---
Your markdown content here...
Required fields
| Field | Format | Description |
|---|---|---|
title | String | The post title |
date | YYYY-MM-DD HH:MM:SS [timezone] | Publication date (see Date formats below) |
description | String | A short description or excerpt |
Optional fields
| Field | Format | Description |
|---|---|---|
slug | String | URL-friendly identifier for the post. If omitted, auto-generated from the title (e.g., "My First Post" becomes "my-first-post") |
featured_image | String | URL or path to a featured image |
Extra fields
Any frontmatter keys beyond the required and optional fields are collected in Post.extras as a Dict(String, String). This is useful for custom metadata like tags, categories, or author names:
---
title: My Post
date: 2025-01-15 00:00:00
description: A post about Gleam
author: Jane Doe
tags: gleam, web
---
Access extras in your views:
import gleam/dict
case dict.get(post.extras, "author") {
Ok(author) -> html.span([], [element.text("By " <> author)])
Error(Nil) -> element.none()
}
Date formats
The date field supports three formats. All dates are internally normalized to UTC.
| Format | Example | Description |
|---|---|---|
| Naive | 2025-01-15 00:00:00 | Interpreted as UTC |
| UTC offset | 2025-01-15 02:00:00 +02:00 | Converted to UTC using the given offset |
| IANA timezone | 2025-01-15 02:00:00 Europe/Helsinki | Converted to UTC using the IANA timezone database |
When a timezone is specified, the date is converted to UTC before being stored in the Post type. This means you can write post dates in your local timezone without manually converting to UTC:
---
title: My Post
date: 2025-01-15 10:30:00 America/New_York
description: Written at 10:30 AM Eastern Time
---
DST transitions are handled automatically — the correct offset is applied based on the date and the timezone’s rules.
Multilingual posts
Language variants use the index-{lang}.{ext} naming convention. Replace {ext} with md, dj, or djot per the source format. Mixed-format directories are allowed:
| Filename | Language |
|---|---|
index.md | Default (no language set, Post.language is None) |
index-en.md | English (Post.language is Some("en")) |
index-it.md | Italian (Post.language is Some("it")) |
index-fr.md | French (Post.language is Some("fr")) |
index.djot | Default, Djot source |
index-it.djot | Italian, Djot source |
Each variant is an independent Post with its own frontmatter. You can have different titles and descriptions per language:
blog/
hello-world/
index.md # title: "Hello World"
index-it.md # title: "Ciao Mondo"
Output paths
When a route_prefix is set (e.g., "blog"):
| Input | Output |
|---|---|
hello-world/index.md | dist/blog/hello-world/index.html |
hello-world/index-it.md | dist/blog/it/hello-world/index.html |
Without a route_prefix:
| Input | Output |
|---|---|
hello-world/index.md | dist/hello-world/index.html |
hello-world/index-it.md | dist/it/hello-world/index.html |
Custom routing with route_builder
For full control over post URLs, use post.route_builder() instead of route_prefix. The route builder receives a PostMetadata value and returns the URL path for that post. When set, the route_prefix is ignored.
import blogatto/config/post
import blogatto/post
import gleam/int
import gleam/option
import gleam/time/calendar
let md =
post.default()
|> post.path("./blog")
|> post.route_builder(fn(meta: post.PostMetadata) {
let #(year, month, _day) = calendar.to_date(meta.date)
"/blog/" <> int.to_string(year) <> "/" <> int.to_string(month) <> "/" <> meta.slug <> "/"
})
This produces date-based URLs like /blog/2024/1/my-post/ and filesystem paths like dist/blog/2024/1/my-post/index.html.
The route builder can also incorporate language:
post.route_builder(fn(meta: post.PostMetadata) {
let lang_prefix = case meta.language {
option.Some(lang) -> "/" <> lang
option.None -> ""
}
lang_prefix <> "/blog/" <> meta.slug <> "/"
})
Blogatto normalizes the returned path: a leading / is added if missing, and a trailing / is appended if missing.
PostMetadata fields
The PostMetadata type contains all frontmatter-derived fields available at routing time:
| Field | Type | Description |
|---|---|---|
title | String | From frontmatter |
slug | String | From frontmatter, or auto-generated from title |
date | Timestamp | From frontmatter |
description | String | From frontmatter |
language | Option(String) | None for default, Some("it") for variants |
featured_image | Option(String) | From frontmatter, if provided |
extras | Dict(String, String) | Additional frontmatter fields |
Note that PostMetadata intentionally excludes url (which is the output of the route builder), excerpt, and contents (which are not available at routing time).
Filtering posts by language
In route views, filter the post list by language to build language-specific pages:
import gleam/list
import gleam/option.{None, Some}
fn english_posts(posts: List(Post(Nil))) -> List(Post(Nil)) {
list.filter(posts, fn(p) {
p.language == None || p.language == Some("en")
})
}
Post assets
Non-markdown files in a post directory (images, PDFs, etc.) are automatically copied to the output directory alongside the generated HTML. This means relative links in your markdown work as expected:
---
title: My Post
date: 2025-01-15 00:00:00
description: A post with images
---

If photo.jpg is in the same directory as index.md, it will be copied to the output and the relative link will resolve correctly.
Markdown parsing options
Blogatto exposes Markdown parsing options that control which extensions are enabled when parsing .md source files. Djot files are not affected — jot follows the djot specification without runtime toggles. Use post.options() to override the defaults returned by post.default_options():
import blogatto/config/post
let opts = post.Options(
footnotes: True,
heading_ids: True,
tables: True,
tasklists: True,
emojis_shortcodes: True,
autolinks: True,
)
let md = post.default()
|> post.path("./blog")
|> post.options(opts)
Options reference
| Field | Default | Description |
|---|---|---|
footnotes | True | Enable footnote parsing |
heading_ids | False | Add id attributes to all headings (enables custom heading IDs) |
tables | True | Enable GFM table parsing |
tasklists | True | Enable task list checkbox parsing |
emojis_shortcodes | True | Convert emoji shortcodes (e.g., :smile:) to Unicode emojis |
autolinks | True | Automatically convert plain URLs into clickable links |
All options default to True except heading_ids, which defaults to False. To get the default options explicitly, use post.default_options().
The Post type
After parsing, each post source file produces a Post(msg) value with these fields:
| Field | Type | Description |
|---|---|---|
title | String | From frontmatter |
slug | String | From frontmatter, or auto-generated from title |
url | String | Absolute URL (e.g., "https://example.com/blog/my-post") |
date | Timestamp | From frontmatter |
description | String | From frontmatter |
excerpt | String | Auto-generated plain-text excerpt from rendered content, truncated to excerpt_len characters |
language | Option(String) | None for default, Some("it") for variants |
featured_image | Option(String) | From frontmatter, if provided |
contents | List(Element(msg)) | Rendered source content as Lustre elements |
extras | Dict(String, String) | Additional frontmatter fields |
The full list of posts is passed to every route view function and is available during feed and sitemap generation.