From Zola

The closest neighbour: one Rust binary, a content tree, a template directory, taxonomies and pagination in the config. The posts stay markdown, since a .md file under content/ is a page (Markdown pages), and a +++ block is TOML here exactly as it is there, so a post copies over as it stands. What has to change is a handful of frontmatter key names, the shortcodes, the internal links and the config; the templates are a rewrite.

NOTE

Converting a post to .typ is a choice, not a migration step. Make it for the pages that want what Typst gives - math, a figure, a template helper mid-page - and leave the rest as they are. Markdown to Typst has the mapping and a pandoc walkthrough for when you do.

The tree

Zola Baudelaire Note
config.toml config.kdl The one real rewrite: the config is KDL only, and every key is checked.
content/**/*.md content/**/*.md Stays markdown, +++ block included. Some key names change.
content/blog/_index.md a blog block in config.kdl There is no per-section file, and a copied one publishes as a page.
content/blog/hello/index.md the same A page bundle is a directory with an index.md in it.
templates/*.html templates/*.typ Tera becomes Typst functions.
templates/shortcodes/ any .typ file you import No registry.
sass/ assets/ Sass compiles here too; see below.
static/ static/ Copied verbatim, same as Zola.
themes/blow theme "themes/blow" Different format; a theme is not portable.
public/ public/ The same default; rename it with paths { dist }.

WARN

Delete the _index.md files as you copy the tree. A .md file is a page, so content/blog/_index.md publishes at /blog/index/, and its block parses cleanly: sort_by, paginate_by and transparent are simply unrecognized keys and do nothing. Nothing fails, and nothing tells you the section is configured nowhere. Those settings become a collection block, below.

The config

config.toml config.kdl
base_url url
title site
description description
default_language lang
[languages.fr] languages { fr { .. } }
theme theme
output_dir paths { dist }
taxonomies = [{ name = "tags" }] content { taxonomies { tags { listing } } }
generate_feeds, feed_filenames generate { feed { formats "atom" } }
build_search_index generate { search { } }
compile_sass always on: a .scss under paths { assets } is compiled
minify_html none
[markdown] highlight_code always on; html { highlight } maps the colours to classes
[markdown] smart_punctuation content { markdown { extensions "smart" } }, off by default as it is there
[link_checker] check { external { } }; skip_prefixes is ignore
[slugify] fixed rules; override per page with slug
[extra] client { } for the browser, typst { inputs } for the compiler

Tables, footnotes, strikethrough and tasklists are on without asking, and the list adds rather than replaces, so extensions "smart" keeps all four and turns typographic quotes on. -name drops a default. The full list is in the reference under content.markdown.extensions.

A Zola section carries its own settings in _index.md. Here they are a collection block:

content {
  collections {
    blog {
      sort "date"
      reverse #true
      template "page.typ"
      paginate { size 10; template "list.typ" }
    }
  }
}
_index.md collection key
sort_by = "date" sort "date" with reverse #true
sort_by = "weight" sort "order"
paginate_by paginate { size }
paginate_path paginate { prefix }
template paginate { template }
page_template template
generate_feeds feed #true, which writes it beside the section index
transparent a glob reaching into the subdirectories
redirect_to a redirect on the destination page

description carries over under its own name, and fills the feed channel the same way Zola’s does.

Frontmatter

The language needs no conversion: +++ is TOML here too, and a block copies over character for character (Markdown pages has all three fences). TOML’s date literal comes with it, so date = 2026-07-09 is read as the ISO day it spells, unquoted, exactly as Zola reads it.

What changes is the names of two keys, and where two of Zola’s tables put their contents:

+++
title = "Hello"
date = 2026-07-09
order = 3                    # was weight
redirect = ["/old/hello/"]   # was aliases
tags = ["rust", "cli"]       # was under [taxonomies]
hero = "cover.png"           # was under [extra]
+++
Zola Baudelaire
title, date, updated, draft, slug the same names
description the same name, and the same job: the feed blurb and the meta description.
weight order
aliases redirect
[taxonomies] top-level keys, one per taxonomy declared in config.kdl.
[extra] top-level keys, read as page.frontmatter.<key>.
template the same name, naming a .typ file under templates/.
path the same name, and the same meaning: the exact URL this page publishes at.
in_search_index none

A taxonomy has to be flat: tags is looked up at the top level and nowhere else, so a [taxonomies] table left in place joins no term. [extra] is a choice - it survives as a dict, so a template can read page.frontmatter.extra.hero instead of flattening it. Flattening is what makes the key look like every other one.

WARN

A key that changed name fails nothing. Anything unrecognized passes through as extra frontmatter, so a post still carrying weight, aliases and a [taxonomies] table builds green with no ordering, no redirect and no terms. Only a near-miss of a real key (titel, oder) is an error. Grep for those three names before you trust the output.

A post you convert to .typ writes the same fields as a Typst dict instead:

#let frontmatter = (
  title: "Hello",
  date: "2026-07-09",
  order: 3,
  redirect: ("/old/hello/",),
  tags: ("rust", "cli"),
  hero: "cover.png",
)

There a list of one needs the trailing comma - ("rust") is a string, ("rust",) is a list - which is the one thing TOML’s ["rust"] never made you think about.

Templates

Tera inheritance becomes ordinary imports. A base template is a function you call around your body, not a file you extend:

<!-- templates/page.html -->
{% extends "base.html" %}
{% block content %}
  <h1>{{ page.title }}</h1>
  {{ page.content | safe }}
{% endblock %}
// templates/page.typ
#import "@baudelaire/html:0.1.0": h
#import "/templates/base.typ": shell

#let page(page, body) = shell(page)[
  #h("h1", page.frontmatter.title)
  #body
]
Tera Typst
{{ page.title }} page.frontmatter.title
{{ page.content | safe }} body
{{ page.extra.hero }} page.frontmatter.at("hero", default: none)
{{ page.taxonomies.tags }} page.taxonomies.at("tags", default: ())
{% for p in section.pages %} for p in pages(page.lang).filter(p => p.collection == "blog")
{% for p in paginator.pages %} for entry in page.frontmatter.entries in a listing template
{% macro %} #let name(args) = ..
{% include %} #import "/templates/parts.typ": ..
{{ get_url(path="style.css") }} "/assets/style.css", rewritten if fingerprinting is on
{{ config.base_url }} url from @baudelaire/site
{{ config.extra.x }} sys.inputs.baudelaire.client.x, from a client { } constant
load_data(path="x.toml") toml("/data/x.toml"), in the page or the template
resize_image() assets { images { responsive } }
get_taxonomy() a taxonomy listing template, or page.taxonomies
page.permalink not available: a page does not know its own URL

That last row is deliberate. Nothing site-wide may enter a page’s wrapper, or every page’s cache identity would depend on every other page. Templates reach the site through @baudelaire/sections and @baudelaire/pages instead, which are tracked as files. See templates.

Template names are yours: there is no page.html / section.html / taxonomy_single.html convention, only the template you bind on a collection, a taxonomy, or a page.

Shortcodes

Nothing interprets {{ .. }} or {% .. %} in a page. In markdown they are prose, so a shortcode you forget ships to the reader as the braces you wrote and no build fails. Grep the tree for them before you call the migration done.

A shortcode becomes a function in a file you import:

// templates/parts.typ
#let youtube(id) = html.elem("iframe", attrs: (
  src: "https://www.youtube-nocookie.com/embed/" + id,
  loading: "lazy",
  allowfullscreen: "true",
))

A markdown page calls it from a fence marked eval, which is the one place a .md page runs Typst:

```typ eval
#import "/templates/parts.typ": youtube
#youtube("dQw4w9WgXcQ")
```

A converted page calls it as #youtube("dQw4w9WgXcQ") in the body. Body shortcodes ({% quote() %} .. {% end %}) take the body as a content argument: #let quote(body) = .., called as #quote[..]. Most shortcodes only wrapped a tag or two and are not worth a function at all: write the markup.

A link names the source file, .md or .typ, and the build rewrites it to that page’s permalink. @/blog/hello.md is Zola’s spelling of the same idea and means nothing here - but it no longer slips through either, since @ is not a directory and the target does not resolve:

× `@/blog/other.md` has no matching page
  ╭─[blog/hello.md:7:16]
7 │ A post. [zola](@/blog/other.md)
  ·                ───────┬───────
  ·                       ╰── no page here
Written What the build does
[a](hello.md), [a](../notes/hello.md) Rewritten to that page’s permalink, resolved against the linking file.
[a](/content/blog/hello.md) The same, resolved against the project root. This is what @/blog/hello.md becomes.
[a](hello.typ), #link("hello.typ") The same again, for a page you converted.
@/blog/hello.md Fails the build, naming the line.
[a](/blog/hello/) Passed through as authored, and not checked.

So the @/ links are a mechanical fix the build finds for you, one page at a time. Old absolute URLs are the ones nothing checks: they survive a copy and break on the first rename, which keeping your URLs is about. See pages.

Raw HTML

Zola passes raw HTML in a post straight through. Here it fails the build, on the line it sits on: the DOM this build produces is typed, and there is nowhere to splice a string of markup into it. Three answers:

Do Get
content { markdown { html "drop" } } An inline run loses its tags and keeps its prose; a block loses its contents with them.
a typ eval fence html.elem("aside", ..), the same element written in the typed form.
convert the page h("div", class: "x")[..] from @baudelaire/html.

An HTML comment is dropped either way: it has nothing to lose.

What Zola wrote that this does not

Zola output Here
/blog/page/1/ not written. Page 1 of an index is only its own URL; a redirects { rules { "/blog/page/1/" "/blog/" } } claims the old path back.
404.html authored: write content/404.typ. Zola ships a default template, so this one is easy to lose in the move.
/blog/hello/cover.png beside the post /assets/blog/hello/cover.png. A colocated file is published under the asset tree, mirroring the directory it sat in, not beside the page.
/style.css from sass/ /assets/style.css. The asset tree’s last path segment is the URL prefix, and only static/ publishes at the root.
search_index.en.js + elasticlunr.min.js search.json, and generate { search { ui } } for a bundled palette. Custom search code is rewritten, not ported.
a feed per term the same, under generate { feed { terms #true } }.

Commands

Zola Baudelaire
zola build baudelaire build
zola serve baudelaire serve
zola check baudelaire check
zola init baudelaire init

Order of work

  1. baudelaire init beside the old site, then copy static/ across and move sass/ into assets/. The .scss files are compiled where they lie and published as .css; partials keep their leading _ and are published by nobody.
  2. Copy content/ across as it stands, +++ blocks included, and delete the _index.md files.
  3. Rename the frontmatter keys that moved: weight to order, aliases to redirect, and lift [taxonomies] to the top level. Nothing fails if you miss one, so this is a grep over the tree, not a build.
  4. Port config.toml to config.kdl, one block at a time. An unknown key fails the build with a suggestion, so this converges fast.
  5. Rewrite the templates one section needs. Everything else can stay unstyled while you work.
  6. Strip the @/ from the internal links - the build names them - then replace the shortcodes and convert the pages that actually want Typst.
  7. baudelaire check until it is quiet, then compare sitemaps as in keeping your URLs.