Docs for v0.0.16.v0.0.18 is the current release.

From Hugo

Hugo has the larger feature surface, so this is the migration where you should check what you actually use. The posts themselves copy across as files: a .md file under content/ is a page here too, and its frontmatter block is read in the language its fence already says it is. --- is YAML, +++ is TOML. What has to change is a handful of frontmatter key names, the shortcodes, the internal links and the config. Go templates and render hooks collapse into ordinary Typst functions.

NOTE

Converting a post to .typ is a choice, not a step. Make it when you want what Typst gives you: math, a figure, a chart, a template helper mid-page. See Markdown pages for what a .md page can carry, and Markdown to Typst for the conversion and its pandoc pass.

The tree

Hugo Baudelaire Note
hugo.toml config.kdl One file, no config/_default/ layering. Use profiles { } for per-environment overrides.
content/**/*.md content/**/*.md Copied as-is, --- or +++ block included. Some key names change; see below.
content/posts/_index.md a posts block in config.kdl No branch-bundle file. Nothing reads a leading _: left in place the file is an ordinary page at /posts/index/. Rename it to content/posts/index.md and it is the page at /posts/.
content/_index.md content/index.md The home page either way: a stem of index under content/ publishes at /, _ or no _.
content/posts/hello/index.md content/posts/hello/index.md Leaf bundles work the same way. index is the stem, whatever the extension; content { index } renames it.
layouts/_default/baseof.html a function every layout calls Composition, not inheritance.
layouts/_default/single.html templates/page.typ Bound by name in the config.
layouts/_default/list.html templates/list.typ Bound under paginate.
layouts/partials/ any .typ file you import No magic directory.
layouts/shortcodes/ any .typ file you import No registry. Called from an eval fence, or directly on a .typ page.
assets/ assets/ Same idea, fewer knobs.
static/ static/ Copied verbatim.
data/ any directory Read with json(), yaml(), toml(), csv() from the page.
i18n/*.toml languages { fr { strings { .. } } } In the config.
archetypes/ baudelaire new The scaffold is built in, not templated.
public/ dist/, renamed with paths { dist }

The config

Hugo Baudelaire
baseURL url
title site
languageCode, defaultContentLanguage lang
languages languages { }
theme theme
publishDir paths { dist }
taxonomies content { taxonomies { } }
permalinks permalink on the collection
pagination.pagerSize, older paginate paginate { size } on the collection
outputs with RSS generate { feed { formats } }; per section, feed #true on the collection
enableRobotsTXT generate { robots { } }
sitemap generate { sitemap #true }
minify assets { minify } for CSS and JS; HTML is not minified
buildDrafts, buildFuture, buildExpired --drafts, --future, and an expiry date that is final
markup.highlight html { highlight }, which maps compiler colours to classes
markup.goldmark.extensions content { markdown { extensions } }
markup.goldmark.renderer.unsafe content { markdown { html } }, and there is no #true: see below
params client { } for the browser, typst { inputs } for the compiler
module none
cascade collection defaults: template, sort, permalink

Hugo infers a section from a directory and layers _index.md, type and cascade on top. Here a directory is a collection, and everything about it is in one block:

content {
  collections {
    posts {
      sort "date"
      reverse #true
      permalink "/{year}/{month}/{slug}/"
      template "page.typ"
      paginate { size 10; template "list.typ" }
    }
  }
}

_root { template "page.typ" } covers the pages directly under content/, which is the closest thing to Hugo’s home and top-level singles.

Goldmark’s defaults and this parser’s are close but not identical. Tables, footnotes, strikethrough and tasklists are on; Hugo’s typographer is not, and is extensions "smart". The full list is in the reference under content.markdown.extensions, where a * marks what you already have.

Frontmatter

The block does not change language. The fence picks it, so a Hugo post’s --- is read as YAML and a +++ as TOML, exactly as Hugo read them. What changes is the spelling of a few keys:

---
title: "Hello"
date: 2026-07-09
lastmod: 2026-08-01
draft: true
weight: 3
aliases: ["/old/hello/", "/older/hello/"]
tags: ["rust", "cli"]
params:
  hero: cover.png
---

becomes

---
title: "Hello"
date: 2026-07-09
updated: 2026-08-01
draft: true
order: 3
redirect: ["/old/hello/", "/older/hello/"]
tags: ["rust", "cli"]
params:
  hero: cover.png
---
Hugo Baudelaire
title, date, draft, slug, description the same names
lastmod updated
expiryDate expiry
publishDate date
weight order
aliases redirect
layout, type template, naming a file in templates/
tags, categories the same names, once content { taxonomies { } } declares them
params: nested keys unchanged; a template reads page.frontmatter.params.hero
url path: the exact URL this page publishes at
post.fr.md, translationKey post.fr.md or an explicit lang, and translation
headless none

WARN

A key nobody recognises is not an error. It lands in page.frontmatter for a template to read, and no built-in behaviour attaches to it: a weight left alone sorts nothing, an aliases left alone publishes no redirect, a url left alone moves no page. Only a key within an edit or two of a real one is caught as a typo, and none of Hugo’s are. So the renames in the table are the whole risk of the copy: do them first, then grep the tree for the old spellings.

WARN

A date is the bare ISO day. hugo new writes date: 2026-07-09T10:00:00+02:00, and that fails the build with a string that is not an ISO day. Cut it to 2026-07-09. TOML’s date literal (date = 2026-07-09) is accepted and reaches the same reader; a TOML datetime does not.

A tags key is nothing at all until the taxonomy is declared. Until then it is an unrecognised key like any other, so a copied post publishes with no terms and no complaint. See taxonomies.

The third fence, ;;;, is KDL, the language the config is written in. Nothing about a Hugo migration needs it, and it is the one dialect that cannot spell a one-element list. See Markdown pages.

A collection can require the fields its template reads, which is the checked version of what Hugo leaves to .Params and an empty string. See schemas.

WARN

Hugo’s .Summary is generated from the first paragraphs or a <!--more--> marker. Nothing here does that: write a description, which then fills the meta tag, the feed entry, the search index and the social card at once. A listing template reads it back as entry.description, and summary is accepted as a spelling of the same key.

The slug is not the same string

This is the one that quietly rewrites every URL. Hugo’s :slug token falls back to the title, urlized. Baudelaire’s slug comes from the filename.

File Hugo, under /:year/:month/:slug/ Here, unchanged
posts/tickets.md, titled “Buying tickets without an app” /2025/11/buying-tickets-without-an-app/ /2025/11/tickets/

So a Hugo site whose filenames are short and whose titles are sentences moves every post the moment you copy the permalink pattern across. Either rename the files to the old slugs, or write slug into each page’s frontmatter, before comparing sitemaps. slug in Hugo frontmatter already pinned the URL and carries over unchanged.

Raw HTML

Hugo’s Goldmark passes HTML through once renderer.unsafe is on, and themes lean on it. Here a .md page that contains any is refused, because the DOM a build produces is typed and a string of markup has nowhere to be spliced into it. Three answers, in the order worth trying:

Do Result
write the element in a typ eval fence #html.elem("aside")[..], and it goes through the same typed DOM as everything else
content { markdown { html "drop" } } an inline run loses its tags and keeps the prose between them; a block-level run is dropped whole, contents included
convert the page to .typ h("div", class: "x")[..], from @baudelaire/html

The refusal names the file, the line and the tag, so a tree is worked through by building it. html "drop" is the one to be careful with: an embedded widget is a block-level run, and dropping it drops what it contained.

Shortcodes and render hooks

Both become Typst functions. A shortcode lives in a file the page or template imports:

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

A markdown page reaches it through an eval fence, which is where {{< youtube id >}} used to be:

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

A .typ page calls #youtube("dQw4w9WgXcQ") outright. Either way eval #false turns the fences off for a site that imports content it did not write.

A render hook, which in Hugo rewrites every link or image in the Markdown, is a #show rule in the template that draws the page, so it applies to a .md body the same as a .typ one:

#let page(page, body) = {
  show link: it => h("span", class: "external", it)
  body
}

WARN

A shortcode left in a page is not an error. {{< youtube .. >}} and {{% notice %}} are ordinary text to a markdown parser, so they publish as text on a green build. Grep for {{< and {{% before you ship.

{{< relref "sleepers.md" >}} and {{< ref >}} have no meaning here, and no error either: they are the text above. What they wrapped is what you keep.

[sleepers](sleepers.md)

A plain link naming a source path is resolved against the linking file and rewritten to that page’s permalink, and if no page is there the build fails. Both extensions work and either may name either kind of page, so a .md post linking a converted .typ one needs no thought. That is the mechanism that finds the links a migration broke.

Href Published as Checked
sleepers.md, sleepers.typ, /posts/a.md that page’s permalink yes; a trailing #part is checked against that page’s headings too
/posts/sleepers/ itself no
https://.., mailto:, #anchor itself no; external links have a pass of their own

So convert {{< relref >}} to the source path, not to the URL it produced: a URL survives a rename only if the URL does. check { links "warn" } demotes the failure to a warning, which is the setting for working through a large tree. See pages.

Templates

<!-- layouts/_default/single.html -->
{{ define "main" }}
  <h1>{{ .Title }}</h1>
  {{ partial "byline.html" . }}
  {{ .Content }}
{{ end }}
// templates/page.typ
#import "@baudelaire/html:0.1.0": h
#import "/templates/parts.typ": shell, byline

#let page(page, body) = shell(page)[
  #h("h1", page.frontmatter.title)
  #byline(page)
  #body
]
Go template Typst
{{ .Title }} page.frontmatter.title
{{ .Content }} body
{{ .Params.hero }} page.frontmatter.at("hero", default: none)
{{ .Date }} page.date.display (and page.date.iso)
{{ range .Pages }} for p in pages(page.lang).filter(p => p.collection == "posts")
{{ range .Paginator.Pages }} for entry in page.frontmatter.entries in a listing template
{{ .Next }}, {{ .Prev }} page.nav.next, page.nav.prev
{{ partial "x.html" . }} #import "/templates/parts.typ": x then #x(page)
{{ define }} / {{ block }} a function that takes the body
{{ .Site.Title }} title from @baudelaire/site
{{ .Site.Params.x }} a client { } constant
{{ .Site.Data.authors }} yaml("/data/authors.yaml")
{{ i18n "readMore" }} page.strings.at("read-more", default: "Read more")
{{ .Permalink }} not available: a page does not know its own URL
resources.Get | toCSS always on: a .scss under paths { assets } is compiled; Tailwind is assets { tailwind { } }
js.Build assets { bundle }
| fingerprint assets { fingerprint } and security { sri }
.Resize "600x" assets { images { responsive { widths .. } } }
render hooks a #show rule in the template

The absent .Permalink is deliberate: nothing site-wide may go into a page’s wrapper, or every page’s cache identity would depend on every other page. The site tree and the page catalogue come from @baudelaire/sections and @baudelaire/pages, which typst tracks as files. See templates.

Page resources

A Hugo page bundle addresses its own files. A bundle here is a directory with an index.md or index.typ in it, and the same files are reached two ways:

Hugo Baudelaire
.Resources.GetMatch "cover.png" page.assets.at("cover.png"), in the template
a hero param naming a file beside the page the same, resolved through page.assets
.Resources.ByType "image" none; page.assets is a dict, filter it yourself
{{ $img.Resize "600x" }} assets { images { responsive } }, applied to every extracted image

The body names it the way Markdown always did:

![Vienna](vienna.png)

The picture publishes at /assets/posts/hello/vienna.png: an extracted image keeps the directories it was authored under, relative to content/, so a tree where every post has its own cover.png does not collide. Only a file the page actually shows is written, so a hero the template draws from page.assets has to be named in the body as well, or it has a URL and no file. See images.

Commands

Hugo Baudelaire
hugo baudelaire build
hugo server baudelaire serve
hugo new content posts/hello.md baudelaire new posts/hello, which scaffolds a .typ
hugo deploy baudelaire deploy
hugo --minify assets { minify }

Order of work

  1. baudelaire init beside the old site, then copy static/, assets/ and content/ across.
  2. Port the config. An unknown key fails the build with a suggestion, so work down the file until it is quiet.
  3. Rename the frontmatter keys in one section, and set its collection’s permalink to whatever permalinks produced before. See keeping your URLs.
  4. Grep that section for {{<, {{% and raw HTML: those are what a page cannot carry across silently.
  5. Rewrite the templates it needs, starting with the shell.
  6. baudelaire check, then compare the old and new sitemaps.

NOTE

Other things Hugo has that have no counterpart: modules, .Summary, related content, taxonomy term pages with their own content files, cascade, output formats beyond the generated ones, and the whole resources chain except bundling, minifying, fingerprinting and image variants. Hugo also writes /posts/page/1/ and a page/1 under every term; nothing here does, and a redirects { rules } pair is what claims those paths back. Check what your theme relies on before you start.