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(_attributes, id, children) {
html.h1([attribute.id(id), attribute.class("post-title")], children)
})
|> post.p(fn(_attributes, children) {
html.p([attribute.class("post-paragraph")], children)
})
Element attributes
Components for every element that can carry author-supplied attributes — a, blockquote, code, h1–h6, img, p, and pre — receive those attributes as a Dict(String, String) in their first argument. In Djot you attach attributes to a block or inline with {...} syntax: a class with {.class-name}, or arbitrary key/value pairs with {key="value"}. This lets a component react to per-element annotations instead of sniffing the rendered content.
import gleam/dict
// A blockquote annotated with `{.pull}` becomes a pull-quote.
post.blockquote(fn(attributes, children) {
let class = case dict.get(attributes, "class") {
Ok("pull") -> "blockquote pull-quote"
_ -> "blockquote"
}
html.blockquote([attribute.class(class)], children)
})
The attribute Dict is always the first parameter; the existing arguments (heading id, link href/title, code language, image src/alt/title) keep their order after it. Components that don’t need the attributes can ignore the parameter with a leading underscore (fn(_attributes, children) { ... }).
Markdown note: CommonMark has no block attribute syntax, so on the Markdown path the dictionary is always empty. Element attributes are a Djot feature.
Component reference
Text elements
| Setter | Signature | Description |
|---|---|---|
post.p | fn(Dict(String, String), List(Element(msg))) -> Element(msg) | Paragraphs (attributes, children) |
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(Dict(String, String), String, List(Element(msg))) -> Element(msg) where the first argument is the element’s attributes and the second is the 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(_attributes, 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(Dict(String, String), String, Option(String), List(Element(msg))) -> Element(msg) | Links (attributes, href, optional title, children) |
post.img | fn(Dict(String, String), String, String, Option(String)) -> Element(msg) | Images (attributes, src, alt text, optional title) |
Example — open external links in a new tab:
import gleam/option.{None, Some}
import gleam/string
post.a(fn(_attributes, 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(Dict(String, String), Option(String), List(Element(msg))) -> Element(msg) | Code spans and fenced blocks (attributes, optional language, children) |
post.pre | fn(Dict(String, String), List(Element(msg))) -> Element(msg) | Preformatted code block wrapper (attributes, children) |
The code component receives Some("gleam") for fenced code blocks with a language tag, or None for inline code. Only fenced blocks carry attributes; inline code receives an empty dictionary.
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(_attributes, lang, children) {
let class = case lang {
Some(l) -> "language-" <> l
None -> ""
}
html.code([attribute.class(class)], children)
})
In Djot you can also annotate a fenced block, e.g. {title="hello.gleam"} on the line before the fence, and read it from attributes:
import gleam/dict
post.code(fn(attributes, lang, children) {
let class = case lang {
Some(l) -> "language-" <> l
None -> ""
}
case dict.get(attributes, "title") {
Ok(title) ->
html.figure([attribute.class("code-figure")], [
html.figcaption([], [element.text(title)]),
html.code([attribute.class(class)], children),
])
Error(_) -> 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(Dict(String, String), List(Element(msg))) -> Element(msg) | Block quotes (attributes, children) |
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.