Blogatto

A Gleam framework for building static blogs with Lustre, Markdown, and Djot.
Blogatto generates your entire static site from a single configuration: blog posts from Markdown (.md) or Djot (.dj/.djot) files with YAML frontmatter, static pages from Lustre views, RSS and Atom feeds, sitemaps, and robots.txt — all rendered via Maud components.
Features
- Blog posts from Markdown or Djot — write in either format with YAML frontmatter, Blogatto handles parsing, rendering, and output
- Multilingual support — add
index-it.md,index-fr.md, etc. alongsideindex.mdfor language variants - Static pages — map URL paths to Lustre view functions that receive the full list of blog posts
- RSS and Atom feeds — generate one or more RSS 2.0 and Atom 1.0 feeds with optional filtering and custom serialization
- Sitemap XML — automatic sitemap generation covering static pages and blog posts
- Robots.txt — configurable crawl policies with sitemap reference
- Custom rendering — override any Markdown or Djot element’s HTML output via shared Maud components
- Build-time syntax highlighting — highlight code blocks at build time via Smalto, with 28 built-in languages and customizable token rendering
- Custom post routing — control blog post URLs with a route builder function for date-based, category-based, or any custom URL scheme
- Blog post templates — full control over the page layout wrapping each blog post
- Static asset copying — copy CSS, images, and other assets into the output directory
How it works
You define a Config using the builder pattern, then call blogatto.build(config). The build pipeline:
- Cleans and recreates the output directory
- Copies static assets
- Generates robots.txt
- Parses post source files (Markdown or Djot), extracts frontmatter, renders HTML, and copies post assets
- Renders static pages from route view functions
- Generates RSS and Atom feeds
- Generates sitemap XML
The output is a fully static site ready to deploy to any static hosting provider.
Documentation
| Guide | Description |
|---|---|
| Getting started | Installation, project setup, and your first build |
| Example blog | Walkthrough of the complete example project |
| Blog posts | Directory structure, frontmatter, multilingual support |
| Configuration | Full configuration reference |
| Post components | Customizing Markdown and Djot rendering |
| Syntax highlighting | Build-time code block highlighting with Smalto |
| Static pages | Routes, view functions, and using post data |
| RSS feeds | RSS 2.0 feed configuration, filtering, and serialization |
| Atom feeds | Atom 1.0 feed configuration, filtering, and serialization |
| Sitemap and robots.txt | Sitemap and crawler configuration |
| Dev server | File watching, auto-rebuild, and live reload |
| Error handling | Error types and recovery patterns |
API reference
Full API documentation is available on HexDocs.
Getting started
This guide walks you through installing Blogatto and building your first static blog.
Prerequisites
- Gleam 1.14.0 or later
- Erlang/OTP 28 or later
Installation
Blogatto is available on Hex. Add it as a dependency to your project:
gleam add blogatto
Project structure
A typical Blogatto project looks like this:
my-blog/
src/
my_blog.gleam # Your build script
blog/
hello-world/
index.md # Blog post (default language)
cover.jpg # Post assets
second-post/
index.md
index-it.md # Italian variant
djot-post/
index.djot # Djot source (also `.dj`)
static/
css/
style.css # Static assets copied to output
images/
logo.png
gleam.toml
Minimal example
The simplest Blogatto setup parses post source files (Markdown or Djot) and writes HTML:
import blogatto
import blogatto/config
import blogatto/config/post
pub fn main() {
let md =
post.default()
|> post.path("./blog")
let cfg =
config.new("https://example.com")
|> config.output_dir("./dist")
|> config.static_dir("./static")
|> config.post(md)
let assert Ok(Nil) = blogatto.build(cfg)
}
Run the build:
gleam run
This produces:
dist/
css/
style.css
images/
logo.png
hello-world/
index.html
cover.jpg
second-post/
index.html
index-it.html
Adding a homepage
Most blogs need a landing page that lists articles. Add a route with a view function that receives the full list of parsed posts:
import blogatto
import blogatto/config
import blogatto/config/post
import blogatto/post.{type Post}
import gleam/list
import gleam/time/timestamp
import lustre/attribute
import lustre/element.{type Element}
import lustre/element/html
pub fn main() {
let md =
post.default()
|> post.path("./blog")
|> post.route_prefix("blog")
let cfg =
config.new("https://example.com")
|> config.output_dir("./dist")
|> config.static_dir("./static")
|> config.post(md)
|> config.route("/", home_view)
let assert Ok(Nil) = blogatto.build(cfg)
}
fn home_view(posts: List(Post(Nil))) -> Element(Nil) {
let sorted =
list.sort(posts, fn(a, b) { timestamp.compare(b.date, a.date) })
html.html([attribute.lang("en")], [
html.head([], [
html.title([], "My Blog"),
html.link([
attribute.rel("stylesheet"),
attribute.href("/css/style.css"),
]),
]),
html.body([], [
html.h1([], [element.text("My Blog")]),
html.ul(
[],
list.map(sorted, fn(p) {
html.li([], [
html.a([attribute.href("/blog/" <> p.slug)], [
element.text(p.title),
]),
])
}),
),
]),
])
}
Full example
See the simple_blog example for a complete project with homepage, blog post template, RSS feed, sitemap, and robots.txt.
Next steps
- Blog posts — learn about frontmatter, multilingual support, and post assets
- Configuration — explore all configuration options
- Syntax highlighting — enable build-time code block highlighting
- Static pages — add more pages and use post data in views
Example blog
Blogatto ships with a complete working example at examples/simple_blog. This page walks through it step by step so you can see how all the pieces fit together.
Project layout
examples/simple_blog/
src/
simple_blog.gleam # Build script
simple_blog/
blog.gleam # Shared config
dev.gleam # Dev server entrypoint
blog/
hello-world/
index.md # Blog post
getting-started/
index.md # Blog post
gleam.toml
Dependencies
The example’s gleam.toml pulls in Blogatto plus the standard library and Lustre:
name = "simple_blog"
version = "1.0.0"
target = "erlang"
[dependencies]
gleam_stdlib = ">= 0.44.0 and < 2.0.0"
lustre = ">= 5.6.0 and < 6.0.0"
gleam_time = ">= 1.7.0 and < 2.0.0"
blogatto = ">= 1.0.0 and < 2.0.0"
Build script
The build configuration lives in src/simple_blog/blog.gleam as a shared module, used by both the build script (src/simple_blog.gleam) and the dev server (src/simple_blog/dev.gleam).
Markdown configuration
The markdown config tells Blogatto where to find posts and how to render them:
let syntax_config = code.default()
let md_config =
post.default()
|> post.path("./blog")
|> post.route_prefix("blog")
|> post.template(blog_post_template)
|> post.syntax_highlighting(syntax_config)
|> post.pre(fn(children) {
html.pre([attribute.class("code-block")], children)
})
|> post.code(fn(language, children) {
let lang_class = case language {
option.Some(lang) -> "language-" <> lang
option.None -> ""
}
html.code([attribute.class(lang_class)], children)
})
path("./blog")— scan theblog/directory for post directoriesroute_prefix("blog")— output posts under/blog/{slug}/template(blog_post_template)— wrap each post in a custom HTML page layoutsyntax_highlighting(syntax_config)— enable build-time syntax highlighting for code blocks (see Syntax highlighting)preandcode— add CSS classes to code block wrappers for styling
RSS feed
let rss_feed =
rss.new(
"Simple Blog",
site_url,
"A simple example blog built with Blogatto",
)
|> rss.language("en-us")
|> rss.generator("Blogatto")
This generates an RSS 2.0 feed with a title, description, and language tag.
Atom feed
let atom_feed =
atom.new(
id: site_url <> "/",
title: atom.PlainText("Simple Blog"),
updated: timestamp.system_time(),
)
|> atom.subtitle("A simple example blog built with Blogatto")
|> atom.link(atom.Link(
href: site_url <> "/atom.xml",
rel: option.Some("self"),
content_type: option.Some("application/atom+xml"),
hreflang: option.None,
title: option.None,
length: option.None,
))
|> atom.generator(atom.Generator(
uri: option.Some("https://github.com/veeso/blogatto"),
version: option.None,
))
This generates an Atom 1.0 feed with a self-link, subtitle, and generator metadata.
Sitemap and robots.txt
let sitemap_config = sitemap.new("/sitemap.xml")
let robots_config =
robots.RobotsConfig(sitemap_url: site_url <> "/sitemap.xml", robots: [
robots.Robot(
user_agent: "*",
allowed_routes: ["/"],
disallowed_routes: [],
),
])
The sitemap collects all routes and blog post URLs into an XML sitemap. The robots.txt allows all crawlers access to the entire site.
Assembling the config
All pieces come together with the builder pattern:
let cfg =
config.new(site_url)
|> config.output_dir("./dist")
|> config.post(md_config)
|> config.route("/", home_view)
|> config.rss_feed(rss_feed)
|> config.atom_feed(atom_feed)
|> config.sitemap(sitemap_config)
|> config.robots(robots_config)
Then a single blogatto.build(cfg) call generates the entire site:
case blogatto.build(cfg) {
Ok(Nil) -> io.println("Site built successfully in ./dist")
Error(err) -> io.println("Build failed: " <> error.describe_error(err))
}
Homepage view
The homepage receives the full list of parsed blog posts and renders them as a linked list, sorted newest-first:
fn home_view(posts: List(Post(Nil))) -> Element(Nil) {
let sorted_posts =
list.sort(posts, fn(a, b) { timestamp.compare(b.date, a.date) })
html.html([attribute.lang("en")], [
html.head([], [
html.meta([attribute.charset("UTF-8")]),
html.meta([
attribute.name("viewport"),
attribute.content("width=device-width, initial-scale=1"),
]),
html.title([], "Simple Blog"),
]),
html.body([], [
html.header([], [
html.h1([], [element.text("Simple Blog")]),
html.p([], [
element.text("A simple example blog built with Blogatto."),
]),
]),
html.main([], [
html.h2([], [element.text("Articles")]),
html.ul(
[],
list.map(sorted_posts, fn(p) {
html.li([], [
html.a([attribute.href("/blog/" <> p.slug)], [
element.text(p.title),
]),
element.text(" — "),
html.em([], [element.text(p.description)]),
])
}),
),
]),
html.footer([], [
html.p([], [
element.text("Built with "),
html.a([attribute.href("https://github.com/veeso/blogatto")], [
element.text("Blogatto"),
]),
]),
]),
]),
])
}
Key points:
- The view receives
List(Post(Nil))— all posts parsed by the markdown pipeline - Posts are sorted by
dateusingtimestamp.comparein reverse order - Each post’s
slug,title, anddescriptionfields are used to build the article list - The route
/blog/{slug}matches theroute_prefix("blog")set in the markdown config
Blog post template
The template function wraps each blog post’s rendered markdown in a full HTML page:
fn blog_post_template(p: Post(Nil), _all_posts: List(Post(Nil))) -> Element(Nil) {
let lang = option.unwrap(p.language, "en")
html.html([attribute.lang(lang)], [
html.head([], [
html.meta([attribute.charset("UTF-8")]),
html.meta([
attribute.name("viewport"),
attribute.content("width=device-width, initial-scale=1"),
]),
html.title([], p.title),
html.meta([
attribute.name("description"),
attribute.content(p.description),
]),
]),
html.body([], [
html.header([], [
html.nav([], [
html.a([attribute.href("/")], [element.text("← Home")]),
]),
]),
html.main([], [
html.article([], [
html.h1([], [element.text(p.title)]),
html.p([], [html.em([], [element.text(p.description)])]),
html.div([], p.contents),
]),
]),
html.footer([], [
html.p([], [
element.text("Built with "),
html.a([attribute.href("https://github.com/veeso/blogatto")], [
element.text("Blogatto"),
]),
]),
]),
]),
])
}
Key points:
p.languageisNonefor the default language andSome("it")for localized variants — here it falls back to"en"_all_postsgives access to all other posts (useful for related posts, navigation, etc.)p.contentsis aList(Element(Nil))containing the rendered markdown — just drop it into a container element- The template adds a navigation link back to the homepage
- SEO metadata (
title,description) is set from the post’s frontmatter fields
Blog posts
Each blog post lives in its own directory under blog/.
hello-world/index.md
---
title: Hello World
date: 2025-01-15 00:00:00
description: Welcome to my new blog built with Blogatto
---
# Hello World
Welcome to my very first blog post! This blog was built using **Blogatto**,
a static site generator for Gleam.
## What is Blogatto?
Blogatto is a framework for building static blogs with Lustre and Markdown...
getting-started/index.md
---
title: Getting Started with Blogatto
date: 2025-01-20 00:00:00
description: Learn how to set up your first static blog with Blogatto
---
# Getting Started with Blogatto
Setting up a blog with Blogatto is straightforward...
Required frontmatter fields are title, date, and description. The slug field is optional — if omitted, it is auto-generated from the title. Any additional fields (e.g. author, tags) are collected into the post’s extras dictionary.
Generated output
Running gleam run from the example directory produces:
dist/
index.html # Homepage
blog/
hello-world/
index.html # Blog post page
getting-started/
index.html # Blog post page
rss.xml # RSS feed
atom.xml # Atom feed
sitemap.xml # Sitemap
robots.txt # Robots policy
Running the example
cd examples/simple_blog
gleam run
The site is written to ./dist. Open dist/index.html in a browser to see the homepage with links to both blog posts.
Running the dev server
The example also includes a dev server entrypoint at src/simple_blog/dev.gleam:
import blogatto/dev
import blogatto/error
import gleam/io
import simple_blog/blog
pub fn main() {
case
blog.config()
|> dev.new()
|> dev.start()
{
Ok(Nil) -> io.println("Dev server stopped.")
Error(err) -> io.println("Dev server error: " <> error.describe_error(err))
}
}
Run it with:
cd examples/simple_blog
gleam run -m simple_blog/dev
This starts a local server at http://127.0.0.1:3000 that watches for file changes, rebuilds the site, and live-reloads the browser. See Dev server for full documentation.
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.
Static pages
Static pages are non-blog HTML pages generated from Lustre view functions. Use them for homepages, about pages, archives, or any page that isn’t a markdown blog post.
Adding routes
Register routes with config.route(path, view). Each route maps a URL path to a view function:
import blogatto/config
let cfg =
config.new("https://example.com")
|> config.route("/", home_view)
|> config.route("/about", about_view)
|> config.route("/archive", archive_view)
Output paths
Routes map to {output_dir}/{route}/index.html:
| Route | Output file |
|---|---|
"/" | dist/index.html |
"/about" | dist/about/index.html |
"/archive" | dist/archive/index.html |
Writing view functions
Every view function receives the full list of blog posts parsed during the build and returns a Lustre Element(msg):
import blogatto/post.{type Post}
import lustre/element.{type Element}
import lustre/element/html
fn about_view(_posts: List(Post(Nil))) -> Element(Nil) {
html.html([], [
html.head([], [html.title([], "About")]),
html.body([], [
html.h1([], [element.text("About this blog")]),
html.p([], [element.text("Welcome to my blog.")]),
]),
])
}
If a page doesn’t need blog post data, ignore the argument with _posts.
Using post data
The post list enables dynamic content on static pages. Common patterns:
Homepage with recent posts
import gleam/list
import gleam/time/timestamp
fn home_view(posts: List(Post(Nil))) -> Element(Nil) {
let recent =
posts
|> list.sort(fn(a, b) { timestamp.compare(b.date, a.date) })
|> list.take(5)
html.html([], [
html.head([], [html.title([], "Home")]),
html.body([], [
html.h1([], [element.text("Latest posts")]),
html.ul(
[],
list.map(recent, fn(p) {
html.li([], [
html.a([attribute.href(p.url)], [element.text(p.title)]),
])
}),
),
]),
])
}
Archive page grouped by year
import gleam/dict
import gleam/int
import gleam/list
import gleam/time/calendar
import gleam/time/timestamp
fn archive_view(posts: List(Post(Nil))) -> Element(Nil) {
// Group posts by year
let by_year =
list.group(posts, fn(p) {
let date = timestamp.to_calendar(p.date, calendar.utc_offset)
date.date.year
})
let years =
by_year
|> dict.to_list()
|> list.sort(fn(a, b) { int.compare(b.0, a.0) })
html.html([], [
html.head([], [html.title([], "Archive")]),
html.body([], [
html.h1([], [element.text("Archive")]),
..list.map(years, fn(entry) {
let #(year, year_posts) = entry
html.section([], [
html.h2([], [element.text(int.to_string(year))]),
html.ul(
[],
list.map(year_posts, fn(p) {
html.li([], [
html.a([attribute.href(p.url)], [element.text(p.title)]),
])
}),
),
])
})
]),
])
}
Filtering by language
import gleam/list
import gleam/option.{None, Some}
fn english_home(posts: List(Post(Nil))) -> Element(Nil) {
let en_posts =
list.filter(posts, fn(p) {
p.language == None || p.language == Some("en")
})
// ... render en_posts
}
Blog post templates
Blog post templates control the full page layout wrapping each rendered blog post. Set a template via post.template():
import blogatto/config/post
import blogatto/post.{type Post}
import gleam/option
import lustre/attribute
import lustre/element.{type Element}
import lustre/element/html
let md =
post.default()
|> post.path("./blog")
|> post.template(post_template)
fn post_template(post: Post(Nil), _all_posts: List(Post(Nil))) -> Element(Nil) {
let lang = option.unwrap(post.language, "en")
html.html([attribute.lang(lang)], [
html.head([], [
html.meta([attribute.charset("UTF-8")]),
html.title([], post.title),
html.meta([
attribute.name("description"),
attribute.content(post.description),
]),
]),
html.body([], [
html.nav([], [
html.a([attribute.href("/")], [element.text("Home")]),
]),
html.article([], [
html.h1([], [element.text(post.title)]),
html.div([], post.contents),
]),
]),
])
}
The template receives a fully parsed Post with rendered contents, and the list of all other posts (useful for related posts, navigation, etc.). When no template is set, Blogatto uses a minimal default that wraps the title and contents in a basic HTML page.
Post components
Blogatto renders Markdown and Djot sources through the same set of Maud-style components — view functions that control how each AST element becomes HTML. You can override any component to add classes, attributes, or entirely custom markup.
The component setters live on blogatto/config/post and apply to every post regardless of source format. Markdown-specific AST nodes (e.g., GFM tables) and Djot-specific AST nodes (e.g., highlight {=...=}) reuse overlapping components (table, mark, …), so a single override applies to both formats.
How it works
The post source is parsed into an AST, then rendered bottom-up: children are rendered first, then passed to the parent component function as List(Element(msg)). When implementing a custom component, you must include the children in the element you return, otherwise they won’t appear in the output.
Djot-only inline constructs that have no matching component (e.g. <span> from [text]{.class}, <ins> from {+text+}, math, symbols) are emitted as raw Lustre elements with their attributes preserved.
Default components
post.default() uses the default Maud components, which render each markdown element as its corresponding HTML element without additional attributes or styling.
import blogatto/config/post
let md = post.default()
|> post.path("./blog")
Overriding components
Each markdown element has a corresponding setter function on PostConfig. Override individual components by piping through the setter:
import blogatto/config/post
import lustre/attribute
import lustre/element/html
let md =
post.default()
|> post.path("./blog")
|> post.h1(fn(id, children) {
html.h1([attribute.id(id), attribute.class("post-title")], children)
})
|> post.p(fn(children) {
html.p([attribute.class("post-paragraph")], children)
})
Component reference
Text elements
| Setter | Signature | Description |
|---|---|---|
post.p | fn(List(Element(msg))) -> Element(msg) | Paragraphs |
post.strong | fn(List(Element(msg))) -> Element(msg) | Bold text |
post.em | fn(List(Element(msg))) -> Element(msg) | Italic text |
post.del | fn(List(Element(msg))) -> Element(msg) | Strikethrough text |
post.mark | fn(List(Element(msg))) -> Element(msg) | Highlighted text |
Headings
All heading setters take fn(String, List(Element(msg))) -> Element(msg) where the first argument is a generated heading ID (useful for anchor links).
| Setter | Description |
|---|---|
post.h1 | Level 1 heading |
post.h2 | Level 2 heading |
post.h3 | Level 3 heading |
post.h4 | Level 4 heading |
post.h5 | Level 5 heading |
post.h6 | Level 6 heading |
Example with anchor links:
post.h2(fn(id, children) {
html.h2([attribute.id(id)], [
html.a([attribute.href("#" <> id)], [element.text("#")]),
element.text(" "),
..children
])
})
Links and images
| Setter | Signature | Description |
|---|---|---|
post.a | fn(String, Option(String), List(Element(msg))) -> Element(msg) | Links (href, optional title, children) |
post.img | fn(String, String, Option(String)) -> Element(msg) | Images (src, alt text, optional title) |
Example — open external links in a new tab:
import gleam/option.{None, Some}
import gleam/string
post.a(fn(href, title, children) {
let attrs = case string.starts_with(href, "http") {
True -> [
attribute.href(href),
attribute.target("_blank"),
attribute.attribute("rel", "noopener noreferrer"),
]
False -> [attribute.href(href)]
}
let attrs = case title {
Some(t) -> [attribute.title(t), ..attrs]
None -> attrs
}
html.a(attrs, children)
})
Code
| Setter | Signature | Description |
|---|---|---|
post.code | fn(Option(String), List(Element(msg))) -> Element(msg) | Code spans and fenced blocks (optional language, children) |
post.pre | fn(List(Element(msg))) -> Element(msg) | Preformatted code block wrapper |
The code component receives Some("gleam") for fenced code blocks with a language tag, or None for inline code.
Tip: Blogatto supports build-time syntax highlighting via Smalto, which automatically tokenizes code blocks and renders styled
<span>elements. When syntax highlighting is enabled, thecodecomponent receives pre-highlighted children. See Syntax highlighting for the full guide.
Example — add language class to code blocks:
import gleam/option.{None, Some}
post.code(fn(lang, children) {
let class = case lang {
Some(l) -> "language-" <> l
None -> ""
}
html.code([attribute.class(class)], children)
})
Lists
| Setter | Signature | Description |
|---|---|---|
post.ul | fn(List(Element(msg))) -> Element(msg) | Unordered lists |
post.ol | fn(Option(Int), List(Element(msg))) -> Element(msg) | Ordered lists (optional start number, children) |
post.li | fn(List(Element(msg))) -> Element(msg) | List items |
post.checkbox | fn(Bool) -> Element(msg) | Task list checkboxes (checked state) |
Tables
| Setter | Signature | Description |
|---|---|---|
post.table | fn(List(Element(msg))) -> Element(msg) | Table wrapper |
post.thead | fn(List(Element(msg))) -> Element(msg) | Table header group |
post.tbody | fn(List(Element(msg))) -> Element(msg) | Table body group |
post.tr | fn(List(Element(msg))) -> Element(msg) | Table row |
post.th | fn(Alignment, List(Element(msg))) -> Element(msg) | Header cell (alignment, children) |
post.td | fn(Alignment, List(Element(msg))) -> Element(msg) | Data cell (alignment, children) |
The Alignment type has three variants: Left, Center, Right.
Example — add alignment classes to table cells:
import blogatto/config/post.{Left, Center, Right}
post.td(fn(alignment, children) {
let class = case alignment {
Left -> "text-left"
Center -> "text-center"
Right -> "text-right"
}
html.td([attribute.class(class)], children)
})
Other elements
| Setter | Signature | Description |
|---|---|---|
post.blockquote | fn(List(Element(msg))) -> Element(msg) | Block quotes |
post.hr | fn() -> Element(msg) | Horizontal rules |
post.footnote | fn(Int, List(Element(msg))) -> Element(msg) | Footnotes (number, children) |
Replacing all components at once
Use post.components() to set a complete Components record:
let my_components = post.Components(
a: my_link,
blockquote: my_blockquote,
// ... all 27 fields
)
let md =
post.default()
|> post.components(my_components)
In most cases, overriding individual components via the setter functions is more convenient.
Syntax highlighting
Blogatto supports build-time syntax highlighting for code blocks in your Markdown files via Smalto. When enabled, fenced code blocks with a language tag (e.g., ```gleam) are highlighted at build time, producing styled HTML with no client-side JavaScript required.
Note: Syntax highlighting currently applies to Markdown (
.md) source files only. Djot (.dj/.djot) code blocks are rendered through the user’scodecomponent without tokenization.
Enabling syntax highlighting
Syntax highlighting is disabled by default. Enable it by passing a SyntaxHighlightingConfig to the markdown configuration:
import blogatto/config/post
import blogatto/config/post/code
let md =
post.default()
|> post.path("./blog")
|> post.syntax_highlighting(code.default())
code.default() includes grammars for 28 languages out of the box.
Supported languages
The default configuration supports the following languages (with aliases):
| Language | Aliases |
|---|---|
| Bash | bash, sh, shell |
| C | c |
| C++ | cpp |
| CSS | css |
| Dart | dart |
| Dockerfile | dockerfile |
| Elixir | elixir |
| Erlang | erlang |
| Gleam | gleam |
| Go | go, golang |
| Haskell | haskell, hs |
| HTML | html |
| Java | java |
| JavaScript | javascript, js |
| JSON | json |
| Kotlin | kotlin, kt |
| Lua | lua |
| Markdown | markdown, md |
| PHP | php |
| Python | python, py |
| Ruby | ruby, rb |
| Rust | rust, rs |
| Scala | scala |
| SQL | sql |
| Swift | swift |
| TOML | toml |
| TypeScript | typescript, ts |
| XML | xml |
| YAML | yaml, yml |
| Zig | zig |
Adding custom languages
If a language you need is not in the default set, add it with code.add_language():
import blogatto/config/post/code
import smalto/languages/ocaml
let syntax_config =
code.default()
|> code.add_language(ocaml.grammar, ["ocaml", "ml"])
The second argument is a list of names that will match the language tag in fenced code blocks.
Styling highlighted code
Smalto renders each token as a <span> with a CSS class corresponding to the token type. By default, the classes are:
| Token type | CSS class |
|---|---|
| Keyword | smalto-keyword |
| String | smalto-string |
| Number | smalto-number |
| Comment | smalto-comment |
| Function | smalto-function |
| Operator | smalto-operator |
| Punctuation | smalto-punctuation |
| Type | smalto-type |
| Module | smalto-module |
| Variable | smalto-variable |
| Constant | smalto-constant |
| Builtin | smalto-builtin |
| Tag | smalto-tag |
| Attribute | smalto-attribute |
| Selector | smalto-selector |
| Property | smalto-property |
| Regex | smalto-regex |
To style your code blocks, add CSS rules for these classes. Here is a minimal dark theme example:
pre.code-block {
background: #1e1e2e;
color: #cdd6f4;
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
}
.smalto-keyword { color: #cba6f7; }
.smalto-string { color: #a6e3a1; }
.smalto-number { color: #fab387; }
.smalto-comment { color: #6c7086; font-style: italic; }
.smalto-function { color: #89b4fa; }
.smalto-operator { color: #89dceb; }
.smalto-punctuation { color: #cdd6f4; }
.smalto-type { color: #f9e2af; }
.smalto-module { color: #f9e2af; }
.smalto-variable { color: #cdd6f4; }
.smalto-constant { color: #fab387; }
.smalto-builtin { color: #f38ba8; }
.smalto-tag { color: #f38ba8; }
.smalto-attribute { color: #f9e2af; }
.smalto-selector { color: #a6e3a1; }
.smalto-property { color: #89b4fa; }
.smalto-regex { color: #f5c2e7; }
Custom token rendering
If CSS classes are not enough, you can override how each token type is rendered using the setter functions on SyntaxHighlightingConfig. Each setter takes a function that receives the token text and returns a Lustre element:
import blogatto/config/post/code
import lustre/attribute
import lustre/element
import lustre/element/html
let syntax_config =
code.default()
|> code.keyword(fn(text) {
html.span(
[attribute.style([#("color", "#cba6f7"), #("font-weight", "bold")])],
[element.text(text)],
)
})
|> code.comment(fn(text) {
html.span(
[attribute.style([#("color", "#6c7086"), #("font-style", "italic")])],
[element.text(text)],
)
})
Available token setters: keyword, string, number, comment, function, operator, punctuation, type_, module, variable, constant, builtin, tag, attribute, selector, property, regex.
There is also a custom setter for custom token types emitted by some grammars. It receives both the custom type name and the token text:
code.custom(fn(type_name, text) {
html.span(
[attribute.class("smalto-custom-" <> type_name)],
[element.text(text)],
)
})
Using with smalto_config
For full control over the underlying Smalto rendering, use code.smalto_config() to pass a custom smalto_lustre.Config directly:
import blogatto/config/post/code
import smalto/lustre as smalto_lustre
let smalto_cfg = smalto_lustre.default_config()
// ... customize via smalto_lustre API ...
let syntax_config =
code.default()
|> code.smalto_config(smalto_cfg)
Customizing the code block wrapper
Syntax highlighting controls the contents of code blocks. To customize the wrapping <pre> and <code> elements, use the markdown component setters:
import blogatto/config/post
import blogatto/config/post/code
import gleam/option
let md =
post.default()
|> post.path("./blog")
|> post.syntax_highlighting(code.default())
|> post.pre(fn(children) {
html.pre([attribute.class("code-block")], children)
})
|> post.code(fn(language, children) {
let lang_class = case language {
option.Some(lang) -> "language-" <> lang
option.None -> ""
}
html.code([attribute.class(lang_class)], children)
})
Complete example
Putting it all together — a markdown config with syntax highlighting, custom wrapper classes, and a custom keyword style:
import blogatto/config/post
import blogatto/config/post/code
import gleam/option
import lustre/attribute
import lustre/element
import lustre/element/html
let syntax_config =
code.default()
|> code.keyword(fn(text) {
html.span(
[attribute.class("token-keyword")],
[element.text(text)],
)
})
let md =
post.default()
|> post.path("./blog")
|> post.route_prefix("blog")
|> post.syntax_highlighting(syntax_config)
|> post.pre(fn(children) {
html.pre([attribute.class("code-block")], children)
})
|> post.code(fn(language, children) {
let lang_class = case language {
option.Some(lang) -> "language-" <> lang
option.None -> ""
}
html.code([attribute.class(lang_class)], children)
})
RSS feeds
Blogatto generates RSS 2.0 feeds from your blog posts. You can configure multiple feeds with different filters (e.g., one per language) and customize how posts are serialized into feed items. RSS feeds work alongside Atom feeds — both can be generated from the same build.
Basic setup
import blogatto/config
import blogatto/config/feed/rss
let rss_feed =
rss.new("My Blog", "https://example.com", "My personal blog")
|> rss.language("en-us")
|> rss.generator("Blogatto")
let cfg =
config.new("https://example.com")
|> config.rss_feed(rss_feed)
This generates dist/rss.xml containing all blog posts with auto-generated excerpts. The excerpt length is controlled by post.excerpt_len() (default: 200 characters).
RssFeedConfig fields
Required fields (passed to rss.new())
| Field | Type | Description |
|---|---|---|
title | String | Channel title |
link | String | Website URL |
description | String | Channel description |
Optional fields (set via builder functions)
| Field | Setter | Default | Description |
|---|---|---|---|
output | rss.output() | "/rss.xml" | Output path relative to output_dir |
language | rss.language() | None | Language code (e.g., "en-us") |
copyright | rss.copyright() | None | Copyright notice |
managing_editor | rss.managing_editor() | None | Editor email |
web_master | rss.web_master() | None | Webmaster email |
pub_date | rss.pub_date() | None | Channel publication date |
last_build_date | rss.last_build_date() | None | Last build timestamp |
categories | rss.category() | [] | Channel category tags (prepends) |
generator | rss.generator() | None | Generator program name |
docs | rss.docs() | None | URL to RSS format documentation |
cloud | rss.cloud() | None | Cloud service for update notifications |
ttl | rss.ttl() | None | Cache time-to-live in minutes |
image | rss.image() | None | Channel image |
text_input | rss.text_input() | None | Channel text input field |
skip_hours | rss.skip_hour() | [] | Hours (0-23) to skip updates (prepends) |
skip_days | rss.skip_day() | [] | Days to skip updates (prepends) |
filter | rss.filter() | None | Include/exclude posts |
serialize | rss.serialize() | None | Custom item serialization |
Filtering posts
Use the filter function to control which posts appear in a feed. The function receives FeedMetadata (from blogatto/config/feed) containing the post and its URL path:
import blogatto/config/feed
import gleam/option
// Only include English posts
let rss_feed =
rss.new("My Blog", "https://example.com", "My personal blog")
|> rss.filter(fn(meta: feed.FeedMetadata(Nil)) {
meta.post.language == option.None
|| meta.post.language == option.Some("en")
})
Custom serialization
Override how posts become feed items with the serialize function:
import blogatto/config/feed
import gleam/dict
import gleam/option.{None, Some}
let rss_feed =
rss.new("My Blog", "https://example.com", "My personal blog")
|> rss.serialize(fn(meta: feed.FeedMetadata(Nil)) {
let author =
dict.get(meta.post.extras, "author")
|> result.map(Some)
|> result.unwrap(None)
rss.RssFeedItem(
title: meta.post.title,
description: meta.post.excerpt,
link: Some(meta.url),
author: author,
comments: None,
source: None,
pub_date: Some(meta.post.date),
categories: [],
enclosure: None,
guid: Some(meta.url),
)
})
Multiple feeds
Call config.rss_feed() multiple times to generate separate feeds:
let en_feed =
rss.new("My Blog (English)", "https://example.com", "My personal blog")
|> rss.language("en-us")
|> rss.filter(fn(meta) {
meta.post.language == option.None
|| meta.post.language == option.Some("en")
})
let it_feed =
rss.new("Il mio Blog (Italiano)", "https://example.com", "Il mio blog personale")
|> rss.output("/rss-it.xml")
|> rss.language("it")
|> rss.filter(fn(meta) {
meta.post.language == option.Some("it")
})
let cfg =
config.new("https://example.com")
|> config.rss_feed(en_feed)
|> config.rss_feed(it_feed)
FeedMetadata
The FeedMetadata(msg) type (from blogatto/config/feed) passed to filter and serialize functions:
| Field | Type | Description |
|---|---|---|
path | String | URL path (e.g., "/blog/my-post") |
post | Post(msg) | The full parsed blog post (includes excerpt field) |
url | String | The absolute URL of the post |
RssFeedItem
The RssFeedItem type returned by serialize functions:
| Field | Type | Description |
|---|---|---|
title | String | Item title (required) |
description | String | Item description (required) |
link | Option(String) | Item URL |
author | Option(String) | Author email or name |
comments | Option(String) | Comments URL |
source | Option(String) | Source feed URL |
pub_date | Option(Timestamp) | Publication date |
categories | List(String) | Category tags |
enclosure | Option(Enclosure) | Media attachment |
guid | Option(String) | Globally unique identifier |
Atom feeds
Blogatto generates Atom 1.0 feeds from your blog posts. You can configure multiple feeds with different filters (e.g., one per language) and customize how posts are serialized into feed entries. Atom feeds work alongside RSS feeds — both can be generated from the same build.
Basic setup
import blogatto/config
import blogatto/config/feed/atom
import gleam/time/timestamp
let atom_feed =
atom.new(
id: "https://example.com/",
title: atom.PlainText("My Blog"),
updated: timestamp.system_time(),
)
|> atom.subtitle("My personal blog")
|> atom.author(atom.Person(
name: "Jane Doe",
email: option.None,
uri: option.None,
))
let cfg =
config.new("https://example.com")
|> config.atom_feed(atom_feed)
This generates dist/atom.xml containing all blog posts with auto-generated summaries. The summary is built from the post excerpt, whose length is controlled by post.excerpt_len() (default: 200 characters).
AtomFeed fields
Required fields (passed to atom.new())
| Field | Type | Description |
|---|---|---|
id | String | Unique feed identifier (typically a URL or URN) |
title | Text | Feed title (PlainText, Html, or XHtml variant) |
updated | Timestamp | Last time the feed was updated |
Optional fields (set via builder functions)
| Field | Setter | Default | Description |
|---|---|---|---|
output | atom.output() | "/atom.xml" | Output path relative to output_dir |
authors | atom.author() | [] | Feed authors (prepends) |
link | atom.link() | None | Feed link (e.g., the homepage) |
categories | atom.category() | [] | Feed categories (prepends) |
contributors | atom.contributor() | [] | Feed contributors (prepends) |
generator | atom.generator() | None | Generator program |
icon | atom.icon() | None | Small icon URL |
logo | atom.logo() | None | Larger logo URL |
rights | atom.rights() | None | Rights/copyright information |
subtitle | atom.subtitle() | None | Feed subtitle or tagline |
filter | atom.filter() | None | Include/exclude posts |
serialize | atom.serialize() | None | Custom entry serialization |
Filtering posts
Use the filter function to control which posts appear in a feed. The function receives FeedMetadata (from blogatto/config/feed) containing the post and its URL path:
import blogatto/config/feed
import gleam/option
// Only include English posts
let atom_feed =
atom.new(
id: "https://example.com/",
title: atom.PlainText("My Blog"),
updated: timestamp.system_time(),
)
|> atom.filter(fn(meta: feed.FeedMetadata(Nil)) {
meta.post.language == option.None
|| meta.post.language == option.Some("en")
})
Custom serialization
Override how posts become feed entries with the serialize function:
import blogatto/config/feed
import blogatto/config/feed/atom
import gleam/option.{None, Some}
let atom_feed =
atom.new(
id: "https://example.com/",
title: atom.PlainText("My Blog"),
updated: timestamp.system_time(),
)
|> atom.serialize(fn(meta: feed.FeedMetadata(Nil)) {
atom.AtomFeedItem(
id: meta.url,
title: atom.PlainText(meta.post.title),
updated: meta.post.date,
authors: [
atom.Person(name: "Jane Doe", email: None, uri: None),
],
content: None,
link: Some(atom.Link(
href: meta.url,
rel: Some("alternate"),
content_type: Some("text/html"),
hreflang: None,
title: None,
length: None,
)),
summary: Some(atom.PlainText(meta.post.excerpt)),
categories: [],
contributors: [],
published: Some(meta.post.date),
rights: None,
source: None,
)
})
Multiple feeds
Call config.atom_feed() multiple times to generate separate feeds:
let en_feed =
atom.new(
id: "https://example.com/atom.xml",
title: atom.PlainText("My Blog (English)"),
updated: timestamp.system_time(),
)
|> atom.filter(fn(meta) {
meta.post.language == option.None
|| meta.post.language == option.Some("en")
})
let it_feed =
atom.new(
id: "https://example.com/atom-it.xml",
title: atom.PlainText("Il mio Blog (Italiano)"),
updated: timestamp.system_time(),
)
|> atom.output("/atom-it.xml")
|> atom.filter(fn(meta) {
meta.post.language == option.Some("it")
})
let cfg =
config.new("https://example.com")
|> config.atom_feed(en_feed)
|> config.atom_feed(it_feed)
FeedMetadata
The FeedMetadata(msg) type (from blogatto/config/feed) passed to filter and serialize functions:
| Field | Type | Description |
|---|---|---|
path | String | URL path (e.g., "/blog/my-post") |
post | Post(msg) | The full parsed blog post (includes excerpt field) |
url | String | The absolute URL of the post |
AtomFeedItem
The AtomFeedItem type returned by serialize functions:
| Field | Type | Description |
|---|---|---|
id | String | Unique entry identifier (required) |
title | Text | Entry title (required) |
updated | Timestamp | Last update timestamp (required) |
authors | List(Person) | Entry authors |
content | Option(Text) | Full entry content |
link | Option(Link) | Entry link (e.g., the post URL) |
summary | Option(Text) | Short summary or description |
categories | List(Category) | Entry categories |
contributors | List(Person) | Entry contributors |
published | Option(Timestamp) | Original publication timestamp |
rights | Option(Text) | Rights information for the entry |
source | Option(Source) | Reference to original feed when syndicated |
Text variants
Atom text fields (title, summary, content, rights) accept three variants:
atom.PlainText(value)— plain text, special characters escaped on serializationatom.Html(value)— HTML content (the string is treated as escaped HTML)atom.XHtml(value)— XHTML content (must be a valid XHTML fragment)
Sitemap and robots.txt
Blogatto can generate a sitemap XML file and a robots.txt file for search engine optimization.
Sitemap
The sitemap includes all static routes and blog post URLs.
Basic setup
import blogatto/config
import blogatto/config/sitemap
import gleam/option.{None}
let sitemap_config =
sitemap.new("/sitemap.xml")
let cfg =
config.new("https://example.com")
|> config.sitemap(sitemap_config)
This generates dist/sitemap.xml with entries for every static route and blog post.
SitemapConfig fields
| Field | Type | Description |
|---|---|---|
path | String | Output path relative to output_dir |
filter | Option(fn(String) -> Bool) | Include/exclude routes by URL |
serialize | Option(fn(String) -> SitemapEntry) | Custom entry serialization |
Filtering routes
Exclude specific routes from the sitemap:
import gleam/string
let sitemap_config =
sitemap.new("/sitemap.xml")
|> sitemap.filter(fn(url) {
// Exclude draft pages
!string.contains(url, "/draft")
})
Custom serialization
Control the priority, change frequency, and last-modified date for each entry:
import blogatto/config/sitemap.{Monthly, Weekly}
import gleam/option.{None, Some}
import gleam/string
let sitemap_config =
sitemap.new("/sitemap.xml")
|> sitemap.serialize(fn(url) {
let #(priority, freq) = case string.contains(url, "/blog/") {
True -> #(0.7, Some(Weekly))
False -> #(1.0, Some(Monthly))
}
sitemap.SitemapEntry(
url: url,
priority: Some(priority),
last_modified: None,
change_frequency: freq,
)
})
SitemapEntry fields
| Field | Type | Description |
|---|---|---|
url | String | The full URL for this entry |
priority | Option(Float) | Priority hint (0.0 to 1.0) |
last_modified | Option(Timestamp) | Last modification date |
change_frequency | Option(ChangeFrequency) | How often the page changes |
ChangeFrequency values
| Value | Description |
|---|---|
Always | Changes every access |
Hourly | Changes approximately every hour |
Daily | Changes approximately every day |
Weekly | Changes approximately every week |
Monthly | Changes approximately every month |
Yearly | Changes approximately every year |
Never | Archived, will not change |
Robots.txt
The robots.txt file tells search engine crawlers which pages to index.
Basic setup
import blogatto/config
import blogatto/config/robots
let robots_config =
robots.new("https://example.com/sitemap.xml")
|> robots.robot(robots.Robot(
user_agent: "*",
allowed_routes: ["/"],
disallowed_routes: [],
))
let cfg =
config.new("https://example.com")
|> config.robots(robots_config)
This generates dist/robots.txt:
Sitemap: https://example.com/sitemap.xml
User-agent: *
Allow: /
Multiple user agents
Add different policies for different crawlers:
let robots_config =
robots.new("https://example.com/sitemap.xml")
|> robots.robot(robots.Robot(
user_agent: "*",
allowed_routes: ["/"],
disallowed_routes: ["/admin/"],
))
|> robots.robot(robots.Robot(
user_agent: "Googlebot",
allowed_routes: ["/"],
disallowed_routes: [],
))
RobotsConfig fields
| Field | Type | Description |
|---|---|---|
sitemap_url | String | Full URL to the sitemap |
robots | List(Robot) | Crawl policies per user agent |
Robot fields
| Field | Type | Description |
|---|---|---|
user_agent | String | Crawler name ("*" for all) |
allowed_routes | List(String) | Paths the crawler may access |
disallowed_routes | List(String) | Paths the crawler must not access |
Combining sitemap and robots.txt
A typical SEO setup uses both together, with the robots.txt pointing to the sitemap:
import blogatto/config
import blogatto/config/robots
import blogatto/config/sitemap
import gleam/option.{None}
let site_url = "https://example.com"
let sitemap_config =
sitemap.new("/sitemap.xml")
let robots_config =
robots.new(site_url <> "/sitemap.xml")
|> robots.robot(robots.Robot(
user_agent: "*",
allowed_routes: ["/"],
disallowed_routes: [],
))
let cfg =
config.new(site_url)
|> config.sitemap(sitemap_config)
|> config.robots(robots_config)
Dev server
Blogatto includes a built-in development server that watches your source files for changes, automatically rebuilds the site, and live-reloads the browser. This eliminates the need for Docker or external servers during development.
Overview
The dev server combines three capabilities into a single dev.start() call:
- File watching — monitors
src/, markdown paths, and static assets for changes using filespy - Auto-rebuild — shells out to a configurable build command with debouncing (~300ms) to batch rapid saves
- Live reload — injects a small script into HTML responses that reloads the browser via Server-Sent Events (SSE) after each successful rebuild
Setup
The dev server needs its own entrypoint module, separate from your build script. This is because the dev server runs your build command as a subprocess — it shells out to gleam run (or whatever you configure) to rebuild the site.
1. Extract your config into a shared module
If your build configuration lives directly in main(), move it to a separate module so both the build script and dev server can use it:
// src/my_blog/blog.gleam
import blogatto/config
import blogatto/config/post
pub fn config() -> config.Config(Nil) {
let md =
post.default()
|> post.path("./blog")
|> post.route_prefix("blog")
config.new("https://example.com")
|> config.output_dir("./dist")
|> config.static_dir("./static")
|> config.post(md)
|> config.route("/", home_view)
}
// ... view functions ...
2. Update your build script
// src/my_blog.gleam
import blogatto
import blogatto/error
import gleam/io
import my_blog/blog
pub fn main() {
case blogatto.build(blog.config()) {
Ok(Nil) -> io.println("Site built successfully!")
Error(err) -> io.println("Build failed: " <> error.describe_error(err))
}
}
3. Create a dev entrypoint
// src/my_blog/dev.gleam
import blogatto/dev
import blogatto/error
import gleam/io
import my_blog/blog
pub fn main() {
case
blog.config()
|> dev.new()
|> dev.build_command("gleam run -m my_blog")
|> dev.port(3000)
|> dev.start()
{
Ok(Nil) -> io.println("Dev server stopped.")
Error(err) -> io.println("Dev server error: " <> error.describe_error(err))
}
}
4. Run the dev server
gleam run -m my_blog/dev
The server starts, performs an initial build, then watches for changes:
🔨 blogatto dev server starting...
👀 Watching for file changes...
👀 Watching: ./static
👀 Watching: ./src/
👀 Watching: ./blog
⟳ Rebuilding...
✓ Rebuild complete
→ http://127.0.0.1:3000
Output: ./dist
Open http://127.0.0.1:3000 in your browser. When you save a file, the site rebuilds and the browser reloads automatically.
Configuration
The DevServer type uses the same builder pattern as the rest of Blogatto:
blog.config()
|> dev.new()
|> dev.build_command("gleam run -m my_blog")
|> dev.port(8080)
|> dev.host("0.0.0.0")
|> dev.live_reload(False)
|> dev.before_build(fn() {
io.println("Starting build...")
Ok(Nil)
})
|> dev.after_build(fn() {
io.println("Build finished!")
Ok(Nil)
})
|> dev.start()
dev.new(config)
Creates a new DevServer from a Blogatto Config. The config is used to derive:
- Output directory — served over HTTP
- Markdown paths — watched for blog post changes
- Static directory — watched for asset changes
src/— always watched for Gleam source changes
dev.build_command(server, command)
Set the shell command executed on each rebuild. Default: "gleam run".
The build command can be anything: gleam run -m my_blog, make build, a shell script, etc. Each invocation recompiles the project and runs your build logic, so source changes are picked up naturally without BEAM hot-reloading.
dev.new(config)
|> dev.build_command("gleam run -m my_blog")
dev.port(server, port)
Set the HTTP server port. Default: 3000.
dev.new(config)
|> dev.port(8080)
dev.host(server, host)
Set the bind address. Default: "127.0.0.1".
To make the server accessible from other devices on your network:
dev.new(config)
|> dev.host("0.0.0.0")
dev.live_reload(server, enabled)
Enable or disable live-reload script injection. Default: True.
When enabled, the dev server:
- Injects a small
<script>tag before</body>in all served HTML responses - Exposes an SSE endpoint at
/__blogatto_dev/reload - After each successful rebuild, sends a reload event to all connected browsers
When disabled, the server still watches and rebuilds, but you must manually refresh the browser.
dev.new(config)
|> dev.live_reload(False)
dev.before_build(server, hook)
Set a function to run before each rebuild. The hook runs before the build command is executed, on every rebuild (including the initial build on startup). This is useful for setup or cleanup tasks.
The hook must return Result(Nil, String). If it returns Error(reason), the build is aborted and the error reason is logged. The hook runs regardless of whether the subsequent build would succeed or fail.
dev.new(config)
|> dev.before_build(fn() {
// Clean generated assets, fetch data, etc.
io.println("Preparing build...")
Ok(Nil)
})
dev.after_build(server, hook)
Set a function to run after each successful rebuild. The hook runs only when the build command exits with code 0. This is useful for post-processing tasks like running Tailwind CSS, copying additional assets, or sending notifications.
The hook must return Result(Nil, String). If it returns Error(reason), the error is logged and browsers are not reloaded. The hook is not called when the build fails.
dev.new(config)
|> dev.after_build(fn() {
case shellout.command("npx", ["tailwindcss", "-o", "./dist/style.css"], ".", []) {
Ok(_) -> Ok(Nil)
Error(_) -> Error("Tailwind CSS compilation failed")
}
})
Reference
| Option | Default | Description |
|---|---|---|
build_command | "gleam run" | Shell command to rebuild the site |
port | 3000 | HTTP server port |
host | "127.0.0.1" | Bind address |
live_reload | True | Inject live-reload script into HTML responses |
before_build | None | fn() -> Result(Nil, String) to run before each rebuild |
after_build | None | fn() -> Result(Nil, String) to run after each successful rebuild |
How it works
Architecture
The dev server is built on OTP actors:
- Rebuild actor — receives file change notifications, debounces them (300ms), shells out to the build command, and broadcasts reload events to connected browsers
- File watcher — uses filespy (which wraps Erlang’s
fslibrary) to monitor directories and send change events to the rebuild actor - HTTP server — mist serves static files from the output directory and manages SSE connections for live reload
Rebuild flow
- A file changes on disk
- The file watcher sends a
FileChangedmessage to the rebuild actor - The rebuild actor cancels any pending debounce timer and starts a new 300ms timer
- When the timer fires, the
before_buildhook runs (if configured) - The actor shells out to the build command
- On success (exit code 0), the
after_buildhook runs (if configured), then aReloadevent is sent to all connected SSE clients - On failure, the error output is logged and the server keeps running with the last successful build (the
after_buildhook is not called)
Watched directories
The dev server watches these directories based on your config:
| Source | Derived from |
|---|---|
| Gleam source code | src/ (always watched) |
| Blog post directories | config.post_config.paths |
| Static assets directory | config.static_dir |
The output directory itself is not watched — it is rebuilt by the build command.
HTTP serving
The dev server serves files from config.output_dir:
- Directory requests (paths ending in
/) resolve toindex.htmlwithin that directory .htmlpaths are served withContent-Type: text/html- Other files are served with the appropriate MIME type based on file extension
- All responses include
Cache-Control: no-cache, no-storeto prevent browser caching - Path traversal attempts (paths containing
..) are rejected with 404 - Missing files return 404
Platform notes
Linux
The file watcher requires inotify-tools to be installed:
# Debian/Ubuntu
sudo apt-get install inotify-tools
# Fedora
sudo dnf install inotify-tools
# Arch Linux
sudo pacman -S inotify-tools
macOS
File watching uses FSEvents natively — no additional setup required.
Troubleshooting
Build command hangs
If the build command prompts for input or enters an infinite loop, it will be killed after 120 seconds and the dev server will report a timeout error. Fix the underlying issue in your build command.
Port already in use
If the port is already bound by another process, dev.start() returns an error. Either stop the other process or use a different port:
dev.new(config) |> dev.port(8080)
No file change events on Linux
Make sure inotify-tools is installed. You may also need to increase the inotify watch limit:
echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf
sudo sysctl -p
Configuration
Blogatto uses a builder pattern for configuration. Start with config.new(site_url) and pipe through setter functions to configure each feature.
Config type
The Config(msg) type holds all settings for a build. The msg type parameter threads through the Lustre message type for type-safe views and components.
import blogatto/config
let cfg =
config.new("https://example.com")
|> config.output_dir("./dist")
|> config.static_dir("./static")
|> config.post(md_config)
|> config.route("/", home_view)
|> config.rss_feed(rss_config)
|> config.atom_feed(atom_config)
|> config.sitemap(sitemap_config)
|> config.robots(robots_config)
Reference
config.new(site_url)
Creates a new Config with the given base URL. The site_url is required because it is used to build absolute URLs for sitemaps, RSS feeds, and blog post URLs.
Default values:
| Field | Default |
|---|---|
output_dir | "./dist" |
static_dir | None (no static asset copying) |
post_config | None (no blog posts) |
routes | Empty (no static pages) |
rss_feeds | Empty (no RSS feeds) |
atom_feeds | Empty (no Atom feeds) |
sitemap | None (no sitemap) |
robots | None (no robots.txt) |
config.output_dir(config, directory)
Set the output directory path. The directory is deleted and recreated on each build.
config.new("https://example.com")
|> config.output_dir("./public")
config.static_dir(config, directory)
Set a static assets directory. During the build, its contents are copied into the root of the output directory.
config.new("https://example.com")
|> config.static_dir("./static")
For example, ./static/css/style.css becomes ./dist/css/style.css.
config.post(config, post_config)
Set the post configuration for blog post rendering (applies to both Markdown and Djot sources). See Post components for component customization and Blog posts for routing details.
import blogatto/config/post
let md = post.default()
|> post.path("./blog")
config.new("https://example.com")
|> config.post(md)
Markdown parsing options
The PostConfig includes an Options record that controls which markdown extensions are enabled during parsing. Use post.options() to override the defaults:
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)
See Markdown parsing options for details on each option.
Syntax highlighting
Enable build-time syntax highlighting for fenced code blocks:
import blogatto/config/post
import blogatto/config/post/code
let md = post.default()
|> post.path("./blog")
|> post.syntax_highlighting(code.default())
See Syntax highlighting for the full guide on supported languages, styling, and customization.
Markdown routing options
The PostConfig controls how blog post URLs are generated. You can use either route_prefix or route_builder (not both — route_builder takes precedence):
post.route_prefix(config, prefix)— set a static URL prefix for all posts (e.g.,"blog"produces/blog/{slug}/)post.route_builder(config, builder)— set a function that receivesPostMetadataand returns a custom URL path per post
See Custom routing with route_builder for examples.
config.route(config, path, view)
Add a static route mapping a URL path to a view function. The view function receives the full list of blog posts parsed during the build.
Routes map to {output_dir}/{route}/index.html in the output.
config.new("https://example.com")
|> config.route("/", home_view)
|> config.route("/about", about_view)
See Static pages for more on writing view functions.
config.rss_feed(config, rss_feed_config)
Add an RSS feed configuration. Can be called multiple times to generate multiple feeds. See RSS feeds.
config.atom_feed(config, atom_feed_config)
Add an Atom 1.0 feed configuration. Can be called multiple times to generate multiple feeds. See Atom feeds.
config.sitemap(config, sitemap_config)
Set the sitemap configuration. See Sitemap and robots.txt.
config.robots(config, robots_config)
Set the robots.txt configuration. See Sitemap and robots.txt.
Common configurations
Blog-only site
A minimal blog with no static pages:
let md =
post.default()
|> post.path("./blog")
let cfg =
config.new("https://example.com")
|> config.post(md)
Blog with homepage
A blog with a homepage listing recent posts:
let md =
post.default()
|> post.path("./blog")
|> post.route_prefix("blog")
let cfg =
config.new("https://example.com")
|> config.static_dir("./static")
|> config.post(md)
|> config.route("/", home_view)
Full-featured site
Blog, multiple pages, RSS, Atom, sitemap, and robots.txt:
let md =
post.default()
|> post.path("./blog")
|> post.route_prefix("blog")
|> post.excerpt_len(300)
|> post.template(post_template)
let cfg =
config.new("https://example.com")
|> config.output_dir("./dist")
|> config.static_dir("./static")
|> config.post(md)
|> config.route("/", home_view)
|> config.route("/about", about_view)
|> config.rss_feed(rss_config)
|> config.atom_feed(atom_config)
|> config.sitemap(sitemap_config)
|> config.robots(robots_config)
See the simple_blog example for a complete working version of this configuration.
Error handling
All Blogatto build functions return Result(Nil, BlogattoError). The library never panics — every failure is surfaced as a Result.
BlogattoError variants
| Variant | Payload | Description |
|---|---|---|
DevServer(String) | Error message | An error occurred in the development server (e.g. file watching, live reload) |
File(FileError) | simplifile.FileError | File system error (reading, writing, deleting files or directories) |
FrontmatterMissing | — | A markdown file has no frontmatter block |
FrontmatterMissingField(String) | Field name | A required frontmatter field (title, date, or description) is missing |
FrontmatterInvalidDate(String) | The date string | The date field could not be parsed as YYYY-MM-DD HH:MM:SS |
FrontmatterInvalidLine(String) | The line content | A frontmatter line could not be parsed as a key: value pair |
InvalidUri(String) | The invalid URI string | A URI could not be parsed during URL resolution |
Handling errors
Basic pattern
import blogatto
import blogatto/error
import gleam/io
case blogatto.build(cfg) {
Ok(Nil) -> io.println("Site built successfully!")
Error(err) -> {
io.println("Build failed: " <> error.describe_error(err))
// Exit with error code, log to monitoring, etc.
}
}
Matching specific errors
import blogatto
import blogatto/error
import gleam/io
case blogatto.build(cfg) {
Ok(Nil) -> io.println("Done!")
Error(error.FrontmatterMissingField(field)) ->
io.println("A post is missing the '" <> field <> "' field in its frontmatter")
Error(error.FrontmatterInvalidDate(date)) ->
io.println("Invalid date format: '" <> date <> "'. Use YYYY-MM-DD HH:MM:SS")
Error(error.FrontmatterMissing) ->
io.println("A markdown file is missing its frontmatter block")
Error(err) ->
io.println("Build failed: " <> error.describe_error(err))
}
describe_error
The error.describe_error(error) function converts any BlogattoError into a human-readable string:
import blogatto/error
error.describe_error(error.FrontmatterMissingField("title"))
// -> "Frontmatter missing required field: title"
error.describe_error(error.FrontmatterInvalidDate("not-a-date"))
// -> "Frontmatter has invalid date format: not-a-date"
error.describe_error(error.InvalidUri(":::bad"))
// -> "Invalid URI: :::bad"
Common issues
Missing frontmatter
Every markdown file must start with a --- delimited frontmatter block. Files without frontmatter produce FrontmatterMissing.
Fix: Add frontmatter to the top of the file:
---
title: My Post
date: 2025-01-15 00:00:00
description: A short description
---
Invalid date format
The date field must follow YYYY-MM-DD HH:MM:SS format exactly.
Valid: 2025-01-15 00:00:00
Invalid: 2025-01-15, Jan 15, 2025, 2025/01/15 00:00:00
File permission errors
File(Eacces) indicates a permissions issue reading source files or writing to the output directory.
Fix: Ensure the output directory’s parent is writable and source files are readable.