Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Blogatto

logo

Package Version Hex Docs conventional-commits target-erlang test

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. alongside index.md for 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:

  1. Cleans and recreates the output directory
  2. Copies static assets
  3. Generates robots.txt
  4. Parses post source files (Markdown or Djot), extracts frontmatter, renders HTML, and copies post assets
  5. Renders static pages from route view functions
  6. Generates RSS and Atom feeds
  7. Generates sitemap XML

The output is a fully static site ready to deploy to any static hosting provider.

Documentation

GuideDescription
Getting startedInstallation, project setup, and your first build
Example blogWalkthrough of the complete example project
Blog postsDirectory structure, frontmatter, multilingual support
ConfigurationFull configuration reference
Post componentsCustomizing Markdown and Djot rendering
Syntax highlightingBuild-time code block highlighting with Smalto
Static pagesRoutes, view functions, and using post data
RSS feedsRSS 2.0 feed configuration, filtering, and serialization
Atom feedsAtom 1.0 feed configuration, filtering, and serialization
Sitemap and robots.txtSitemap and crawler configuration
Dev serverFile watching, auto-rebuild, and live reload
Error handlingError 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

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

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 the blog/ directory for post directories
  • route_prefix("blog") — output posts under /blog/{slug}/
  • template(blog_post_template) — wrap each post in a custom HTML page layout
  • syntax_highlighting(syntax_config) — enable build-time syntax highlighting for code blocks (see Syntax highlighting)
  • pre and code — 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 date using timestamp.compare in reverse order
  • Each post’s slug, title, and description fields are used to build the article list
  • The route /blog/{slug} matches the route_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.language is None for the default language and Some("it") for localized variants — here it falls back to "en"
  • _all_posts gives access to all other posts (useful for related posts, navigation, etc.)
  • p.contents is a List(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:

ExtensionFormatParser
.mdCommonMarkmork
.djDjotjot
.djotDjotjot

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 (Options record) only affect .md files. 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

FieldFormatDescription
titleStringThe post title
dateYYYY-MM-DD HH:MM:SS [timezone]Publication date (see Date formats below)
descriptionStringA short description or excerpt

Optional fields

FieldFormatDescription
slugStringURL-friendly identifier for the post. If omitted, auto-generated from the title (e.g., "My First Post" becomes "my-first-post")
featured_imageStringURL 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.

FormatExampleDescription
Naive2025-01-15 00:00:00Interpreted as UTC
UTC offset2025-01-15 02:00:00 +02:00Converted to UTC using the given offset
IANA timezone2025-01-15 02:00:00 Europe/HelsinkiConverted 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:

FilenameLanguage
index.mdDefault (no language set, Post.language is None)
index-en.mdEnglish (Post.language is Some("en"))
index-it.mdItalian (Post.language is Some("it"))
index-fr.mdFrench (Post.language is Some("fr"))
index.djotDefault, Djot source
index-it.djotItalian, 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"):

InputOutput
hello-world/index.mddist/blog/hello-world/index.html
hello-world/index-it.mddist/blog/it/hello-world/index.html

Without a route_prefix:

InputOutput
hello-world/index.mddist/hello-world/index.html
hello-world/index-it.mddist/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:

FieldTypeDescription
titleStringFrom frontmatter
slugStringFrom frontmatter, or auto-generated from title
dateTimestampFrom frontmatter
descriptionStringFrom frontmatter
languageOption(String)None for default, Some("it") for variants
featured_imageOption(String)From frontmatter, if provided
extrasDict(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
---

![Photo](./photo.jpg)

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

FieldDefaultDescription
footnotesTrueEnable footnote parsing
heading_idsFalseAdd id attributes to all headings (enables custom heading IDs)
tablesTrueEnable GFM table parsing
tasklistsTrueEnable task list checkbox parsing
emojis_shortcodesTrueConvert emoji shortcodes (e.g., :smile:) to Unicode emojis
autolinksTrueAutomatically 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:

FieldTypeDescription
titleStringFrom frontmatter
slugStringFrom frontmatter, or auto-generated from title
urlStringAbsolute URL (e.g., "https://example.com/blog/my-post")
dateTimestampFrom frontmatter
descriptionStringFrom frontmatter
excerptStringAuto-generated plain-text excerpt from rendered content, truncated to excerpt_len characters
languageOption(String)None for default, Some("it") for variants
featured_imageOption(String)From frontmatter, if provided
contentsList(Element(msg))Rendered source content as Lustre elements
extrasDict(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:

RouteOutput 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

SetterSignatureDescription
post.pfn(List(Element(msg))) -> Element(msg)Paragraphs
post.strongfn(List(Element(msg))) -> Element(msg)Bold text
post.emfn(List(Element(msg))) -> Element(msg)Italic text
post.delfn(List(Element(msg))) -> Element(msg)Strikethrough text
post.markfn(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).

SetterDescription
post.h1Level 1 heading
post.h2Level 2 heading
post.h3Level 3 heading
post.h4Level 4 heading
post.h5Level 5 heading
post.h6Level 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
  ])
})
SetterSignatureDescription
post.afn(String, Option(String), List(Element(msg))) -> Element(msg)Links (href, optional title, children)
post.imgfn(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

SetterSignatureDescription
post.codefn(Option(String), List(Element(msg))) -> Element(msg)Code spans and fenced blocks (optional language, children)
post.prefn(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, the code component 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

SetterSignatureDescription
post.ulfn(List(Element(msg))) -> Element(msg)Unordered lists
post.olfn(Option(Int), List(Element(msg))) -> Element(msg)Ordered lists (optional start number, children)
post.lifn(List(Element(msg))) -> Element(msg)List items
post.checkboxfn(Bool) -> Element(msg)Task list checkboxes (checked state)

Tables

SetterSignatureDescription
post.tablefn(List(Element(msg))) -> Element(msg)Table wrapper
post.theadfn(List(Element(msg))) -> Element(msg)Table header group
post.tbodyfn(List(Element(msg))) -> Element(msg)Table body group
post.trfn(List(Element(msg))) -> Element(msg)Table row
post.thfn(Alignment, List(Element(msg))) -> Element(msg)Header cell (alignment, children)
post.tdfn(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

SetterSignatureDescription
post.blockquotefn(List(Element(msg))) -> Element(msg)Block quotes
post.hrfn() -> Element(msg)Horizontal rules
post.footnotefn(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’s code component 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):

LanguageAliases
Bashbash, sh, shell
Cc
C++cpp
CSScss
Dartdart
Dockerfiledockerfile
Elixirelixir
Erlangerlang
Gleamgleam
Gogo, golang
Haskellhaskell, hs
HTMLhtml
Javajava
JavaScriptjavascript, js
JSONjson
Kotlinkotlin, kt
Lualua
Markdownmarkdown, md
PHPphp
Pythonpython, py
Rubyruby, rb
Rustrust, rs
Scalascala
SQLsql
Swiftswift
TOMLtoml
TypeScripttypescript, ts
XMLxml
YAMLyaml, yml
Zigzig

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 typeCSS class
Keywordsmalto-keyword
Stringsmalto-string
Numbersmalto-number
Commentsmalto-comment
Functionsmalto-function
Operatorsmalto-operator
Punctuationsmalto-punctuation
Typesmalto-type
Modulesmalto-module
Variablesmalto-variable
Constantsmalto-constant
Builtinsmalto-builtin
Tagsmalto-tag
Attributesmalto-attribute
Selectorsmalto-selector
Propertysmalto-property
Regexsmalto-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())

FieldTypeDescription
titleStringChannel title
linkStringWebsite URL
descriptionStringChannel description

Optional fields (set via builder functions)

FieldSetterDefaultDescription
outputrss.output()"/rss.xml"Output path relative to output_dir
languagerss.language()NoneLanguage code (e.g., "en-us")
copyrightrss.copyright()NoneCopyright notice
managing_editorrss.managing_editor()NoneEditor email
web_masterrss.web_master()NoneWebmaster email
pub_daterss.pub_date()NoneChannel publication date
last_build_daterss.last_build_date()NoneLast build timestamp
categoriesrss.category()[]Channel category tags (prepends)
generatorrss.generator()NoneGenerator program name
docsrss.docs()NoneURL to RSS format documentation
cloudrss.cloud()NoneCloud service for update notifications
ttlrss.ttl()NoneCache time-to-live in minutes
imagerss.image()NoneChannel image
text_inputrss.text_input()NoneChannel text input field
skip_hoursrss.skip_hour()[]Hours (0-23) to skip updates (prepends)
skip_daysrss.skip_day()[]Days to skip updates (prepends)
filterrss.filter()NoneInclude/exclude posts
serializerss.serialize()NoneCustom 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:

FieldTypeDescription
pathStringURL path (e.g., "/blog/my-post")
postPost(msg)The full parsed blog post (includes excerpt field)
urlStringThe absolute URL of the post

RssFeedItem

The RssFeedItem type returned by serialize functions:

FieldTypeDescription
titleStringItem title (required)
descriptionStringItem description (required)
linkOption(String)Item URL
authorOption(String)Author email or name
commentsOption(String)Comments URL
sourceOption(String)Source feed URL
pub_dateOption(Timestamp)Publication date
categoriesList(String)Category tags
enclosureOption(Enclosure)Media attachment
guidOption(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())

FieldTypeDescription
idStringUnique feed identifier (typically a URL or URN)
titleTextFeed title (PlainText, Html, or XHtml variant)
updatedTimestampLast time the feed was updated

Optional fields (set via builder functions)

FieldSetterDefaultDescription
outputatom.output()"/atom.xml"Output path relative to output_dir
authorsatom.author()[]Feed authors (prepends)
linkatom.link()NoneFeed link (e.g., the homepage)
categoriesatom.category()[]Feed categories (prepends)
contributorsatom.contributor()[]Feed contributors (prepends)
generatoratom.generator()NoneGenerator program
iconatom.icon()NoneSmall icon URL
logoatom.logo()NoneLarger logo URL
rightsatom.rights()NoneRights/copyright information
subtitleatom.subtitle()NoneFeed subtitle or tagline
filteratom.filter()NoneInclude/exclude posts
serializeatom.serialize()NoneCustom 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:

FieldTypeDescription
pathStringURL path (e.g., "/blog/my-post")
postPost(msg)The full parsed blog post (includes excerpt field)
urlStringThe absolute URL of the post

AtomFeedItem

The AtomFeedItem type returned by serialize functions:

FieldTypeDescription
idStringUnique entry identifier (required)
titleTextEntry title (required)
updatedTimestampLast update timestamp (required)
authorsList(Person)Entry authors
contentOption(Text)Full entry content
linkOption(Link)Entry link (e.g., the post URL)
summaryOption(Text)Short summary or description
categoriesList(Category)Entry categories
contributorsList(Person)Entry contributors
publishedOption(Timestamp)Original publication timestamp
rightsOption(Text)Rights information for the entry
sourceOption(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 serialization
  • atom.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

FieldTypeDescription
pathStringOutput path relative to output_dir
filterOption(fn(String) -> Bool)Include/exclude routes by URL
serializeOption(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

FieldTypeDescription
urlStringThe full URL for this entry
priorityOption(Float)Priority hint (0.0 to 1.0)
last_modifiedOption(Timestamp)Last modification date
change_frequencyOption(ChangeFrequency)How often the page changes

ChangeFrequency values

ValueDescription
AlwaysChanges every access
HourlyChanges approximately every hour
DailyChanges approximately every day
WeeklyChanges approximately every week
MonthlyChanges approximately every month
YearlyChanges approximately every year
NeverArchived, 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

FieldTypeDescription
sitemap_urlStringFull URL to the sitemap
robotsList(Robot)Crawl policies per user agent

Robot fields

FieldTypeDescription
user_agentStringCrawler name ("*" for all)
allowed_routesList(String)Paths the crawler may access
disallowed_routesList(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:

  1. File watching — monitors src/, markdown paths, and static assets for changes using filespy
  2. Auto-rebuild — shells out to a configurable build command with debouncing (~300ms) to batch rapid saves
  3. 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:

  1. Injects a small <script> tag before </body> in all served HTML responses
  2. Exposes an SSE endpoint at /__blogatto_dev/reload
  3. 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

OptionDefaultDescription
build_command"gleam run"Shell command to rebuild the site
port3000HTTP server port
host"127.0.0.1"Bind address
live_reloadTrueInject live-reload script into HTML responses
before_buildNonefn() -> Result(Nil, String) to run before each rebuild
after_buildNonefn() -> 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 fs library) to monitor directories and send change events to the rebuild actor
  • HTTP servermist serves static files from the output directory and manages SSE connections for live reload

Rebuild flow

  1. A file changes on disk
  2. The file watcher sends a FileChanged message to the rebuild actor
  3. The rebuild actor cancels any pending debounce timer and starts a new 300ms timer
  4. When the timer fires, the before_build hook runs (if configured)
  5. The actor shells out to the build command
  6. On success (exit code 0), the after_build hook runs (if configured), then a Reload event is sent to all connected SSE clients
  7. On failure, the error output is logged and the server keeps running with the last successful build (the after_build hook is not called)

Watched directories

The dev server watches these directories based on your config:

SourceDerived from
Gleam source codesrc/ (always watched)
Blog post directoriesconfig.post_config.paths
Static assets directoryconfig.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 to index.html within that directory
  • .html paths are served with Content-Type: text/html
  • Other files are served with the appropriate MIME type based on file extension
  • All responses include Cache-Control: no-cache, no-store to 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:

FieldDefault
output_dir"./dist"
static_dirNone (no static asset copying)
post_configNone (no blog posts)
routesEmpty (no static pages)
rss_feedsEmpty (no RSS feeds)
atom_feedsEmpty (no Atom feeds)
sitemapNone (no sitemap)
robotsNone (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 receives PostMetadata and 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)

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

VariantPayloadDescription
DevServer(String)Error messageAn error occurred in the development server (e.g. file watching, live reload)
File(FileError)simplifile.FileErrorFile system error (reading, writing, deleting files or directories)
FrontmatterMissingA markdown file has no frontmatter block
FrontmatterMissingField(String)Field nameA required frontmatter field (title, date, or description) is missing
FrontmatterInvalidDate(String)The date stringThe date field could not be parsed as YYYY-MM-DD HH:MM:SS
FrontmatterInvalidLine(String)The line contentA frontmatter line could not be parsed as a key: value pair
InvalidUri(String)The invalid URI stringA 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.