Creation of this blog

15 minutes read •


A Basic Guide to Zola 0.23.6

Zola is a very fast static site generator (SSG) written in Rust. It runs as a single binary from the terminal.


1. Installation and Verification

To install Zola on Arch Linux from the official repositories:

sudo pacman -S zola

Check the installed version, if it’s not 0.23.6 It may not work this tutorial:

zola --version

2. Basic Workflow

Step 1: Create a new project

From the folder where you want to keep the project (in my case ~/dev ), run:

zola init my-site

Zola will ask you three initial questions. You can change all of them later in zola.toml.

What is the URL of your site? (https://example.com):

The URL where the site will be published, for example https://slvdr510.dev. It’s stored as base_url and used to generate absolute links (permalink).

If you just press Enter, the answer isn’t left empty: Zola uses the default shown in brackets and writes base_url = "https://example.com". That’s fine while you develop locally, but change it before publishing. Don’t set it to an empty string either (base_url = ""): zola build refuses to run and shows A base URL is required in config.toml with key base_url.

Do you want to enable Sass compilation? [Y/n]:

This lets you put your .scss or .sass files in the sass/ folder and Zola will compile them automatically. The default is Y.

Do you want to build a search index of the content? [y/N]:

The main reason this is off by default (N) is to keep the site fast and small, especially for small or simple sites.

Here’s why you might not want it:

When SHOULD you enable it?

Zola then creates the following folder structure:

my-site/
├── zola.toml         # Main site configuration
├── content/          # Markdown files (.md)
├── sass/             # SCSS/SASS files for styles
├── static/           # Static files (images, fonts, JS)
├── templates/        # HTML templates (Tera)
└── themes/           # Installed themes (optional)

Note: in older versions of Zola this file was called config.toml. Many guides and themes still call it that; it’s the same file.

Syntax highlighting for code blocks is already enabled: zola.toml has a [markdown.highlighting] section with the catppuccin-mocha theme, which you can swap for another one.


Step 2: Live development server

Go into your project folder and start the local server:

cd my-site
zola serve

Step 3: Add content

Inside the content folder, create a new folder called blog and, inside it, an _index.md file with the following content:

+++
title = "Blog"
sort_by = "date"
template = "section.html"
+++

template sets which template renders the /blog/ page. section.html is the one Zola uses for sections by default, so that line is optional. Don’t put index.html here, because that template is for the home page (/).

Then, in content/blog/, create a file called, for example, my-first-post.md:

+++
title = "My first post"
date = 2026-10-07
+++

# Hello from Arch Linux!

This is my first post generated with **Zola**.

Now let’s create the templates in the templates folder. Zola uses a different template for each kind of page:

TemplateRendersMain variable
index.htmlThe home page (/)section (the root section)
section.htmlEach section, such as /blog/section
page.htmlEach post, such as /blog/my-first-post/page

If one is missing, Zola doesn’t fail; it shows a “Welcome to Zola!” page instead.

Let’s start with index.html, the home page:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>slvdr510.dev</title>
</head>
<body>
    <h1>Welcome to my site</h1>
    <p>Latest posts from my blog:</p>

    <!-- Zola looks up the blog section and lists its pages -->
    {% set blog_section = get_section(path="blog/_index.md") %}
    <ul>
        {% for page in blog_section.pages %}
            <li>
                <a href="{{ page.permalink }}">{{ page.title }}</a>
                <span>({{ page.date }})</span>
            </li>
        {% endfor %}
    </ul>
</body>
</html>

Since the home page isn’t the blog section, we use get_section to load it and loop over its posts.

Next, create page.html, the template for each post:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>{{ page.title }}</title>
</head>
<body>
    <a href="/">← Back to home</a>
    <hr>
    <h1>{{ page.title }}</h1>
    <p><em>Published on {{ page.date }}</em></p>

    <div>
        {{ page.content | safe }}
    </div>
</body>
</html>

And finally section.html, which renders /blog/ with the list of posts in the section:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>{{ section.title }}</title>
</head>
<body>
    <a href="/">← Home</a>
    <h1>{{ section.title }}</h1>

    <ul>
        {% for page in section.pages %}
            <li>
                <a href="{{ page.permalink }}">{{ page.title }}</a>
            </li>
        {% endfor %}
    </ul>
</body>
</html>

Watch out: showing Tera code inside a post

Zola processes the content of .md files as a Tera template, including what’s inside code blocks. If a post shows code with {{ }} or {% %} (like this one), Zola will try to run it and fail with errors such as Section 'blog/_index.md' not found.

To show it as-is, wrap the code block in {% raw %} and {% endraw %}:

{% raw %}
```html
<h1>{{ page.title }}</h1>
```
{% endraw %}

Step 4: Build for production

To generate the final site, ready to upload to a host (GitHub Pages, Netlify, Vercel, etc.):

zola build

This creates the public/ folder with all the HTML, CSS and static assets.

Zola writes every internal link as an absolute URL built from base_url: a link to the blog becomes https://slvdr510.dev/blog/, not /blog/. That’s fine on the real domain, but if the same build is served from anywhere else (a host’s preview address like my-site.pages.dev, or a local server), every click and every stylesheet points back to base_url. Before the domain is set up, that means unstyled pages and broken links.

Zola has no option for relative links, and base_url can’t be left empty (see step 1). So this site fixes it after the build, with a small script that rewrites internal links in the generated HTML to root-relative paths. Create scripts/relative_links.sh:

#!/bin/sh
# Turns the internal links of the generated HTML (href, src, srcset) from absolute
# (https://slvdr510.dev/blog/...) into root-relative (/blog/...), so the site
# works on any domain: slvdr510.dev, *.pages.dev or localhost.
# The canonical URL, og: tags, feed and sitemap keep using base_url.
#
# Usage, from the project root and after `zola build`:
#   sh scripts/relative_links.sh

set -eu

base=$(sed -n 's/^base_url *= *"\(.*\)"/\1/p' zola.toml)
base=${base%/}
if [ -z "$base" ]; then
    echo "base_url not found in zola.toml" >&2
    exit 1
fi
# Escape the dots to use the URL inside a regular expression
base_re=$(printf '%s' "$base" | sed 's/\./\\./g')

find public -name '*.html' -exec sed -i -E \
    -e "/rel=\"canonical\"/! s#(href|src|srcset)=\"$base_re\"#\\1=\"/\"#g" \
    -e "/rel=\"canonical\"/! s#(href|src|srcset)=\"$base_re/#\\1=\"/#g" \
    {} +

It reads base_url from zola.toml, so nothing is hard-coded. Then it replaces it in every href, src and srcset attribute of every page in public/. The second expression covers links like https://slvdr510.dev/blog/. The first covers links to the bare domain (https://slvdr510.dev, used by the home button), which would otherwise become an empty href="" that points to the current page instead of home.

Some URLs are left absolute on purpose:

Run it after every production build:

zola build && sh scripts/relative_links.sh

If your host runs the build for you, add && sh scripts/relative_links.sh to the end of its build command too. zola serve doesn’t need it, because it already builds for 127.0.0.1.

Step 6: Ignore generated files in Git

If you keep the site in a Git repository, the public/ folder shouldn’t go in it: Zola rebuilds it from scratch on every zola build, and most hosts run the build themselves. Create a .gitignore file in the project root:

# Site generated by `zola build` (and the Pagefind index, which also goes here).
# It's rebuilt on every build, so it isn't versioned.
public/

# Folder settings created by Dolphin (KDE)
.directory

The .directory line is only needed if you use the Dolphin file manager, which drops that file into folders whose view settings you change. Add whatever your own system or editor creates in the same way.


3. Command Summary

CommandDescription
zola init <name>Creates a new project with the initial structure.
zola serveStarts the local development server with live reloading.
zola buildBuilds the site into the public/ folder.
zola checkChecks for broken links and template errors.

4. Installing a Theme (Optional)

The templates from step 3 are enough for a working blog. If you’d rather use a ready-made design (styles, dark mode, copy-code button, tags, feed…), you can use a theme. As an example, here’s how to install xuan, the theme this site uses:

  1. Browse themes on the official site: getzola.org/themes. Make sure the theme is maintained and compatible with your Zola version (xuan requires 0.23.6 or later).
  2. Clone the theme into the themes/ folder:
    git clone https://github.com/jhq223/xuan.git themes/xuan
  3. Enable it in your zola.toml, along with the title, the language and the theme options you want. All of them are documented in themes/xuan/zola.toml. This is the configuration this site uses:
    base_url = "https://slvdr510.dev"
    
    title = "slvdr510.dev"
    description = "Personal blog about Linux and development."
    default_language = "en"
    
    theme = "xuan"
    
    compile_sass = true
    build_search_index = false
    
    # No RSS/Atom feed (see "Top bar options")
    generate_feeds = false
    feed_filenames = ["atom.xml"]
    taxonomies = [{ name = "tags", feed = true }]
    generate_robots_txt = true
    
    [markdown]
    smart_punctuation = true
    bottom_footnotes = true
    # Note/warning boxes with "> [!NOTE]" (see "Notes and warnings in posts")
    github_alerts = true
    
    [markdown.highlighting]
    # One code theme for light mode and one for dark mode, picked from the system setting
    light_theme = "github-light-default"
    dark_theme = "andromeeda"
    
    [extra]
    # Accent color for the light and dark themes (see "Accent color and post buttons")
    accent_color = "#9ca0fc"
    accent_color_dark = "#737cff"
    # No theme copy button: code blocks are copied by clicking them (see "Customizing part of the theme")
    show_copy_button = false
    show_reading_time = true
    show_backlinks = true
    # Custom CSS, compiled from sass/custom.scss (see "Customizing part of the theme")
    styles = ["custom.css"]
    # Custom JavaScript, from static/ (see "Customizing part of the theme" and "Images in posts")
    scripts = ["copy-code.js", "lightbox.js"]
    # Pagefind search: requires running `npx pagefind --site public` after every `zola build`
    search = false
    
    [extra.nav]
    show_feed = true
    # Hides the light/dark theme button (see "Top bar options")
    show_theme_switcher = false
    show_repo = false
    links = [
        { url = "@/blog/_index.md", name = "Blog" },
        { url = "@/archive/_index.md", name = "Archive" },
    ]
    
    [extra.footer]
    # No links above the copyright line
    links = []
    # Social icon buttons (icon shortened here, see "Cleaning up the footer")
    socials = [
        { url = "https://github.com/slvdr510", name = "GitHub", icon = "%3Csvg role='img' viewBox='0 0 24 24' ..." },
        { url = "https://www.youtube.com/@slvdr510", name = "YouTube", icon = "%3Csvg role='img' viewBox='0 0 24 24' ..." },
        { url = "https://x.com/slvdr510", name = "X", icon = "%3Csvg role='img' viewBox='0 0 24 24' ..." },
    ]
    # Hides the "© site name, year" line
    show_copyright = false
    # Hides the "Powered by Zola and Xuan" line
    show_powered_by = false
    show_source = false
  4. In content/blog/_index.md, set the theme’s templates for the post list and for each post:
    +++
    title = "Blog"
    sort_by = "date"
    template = "article_list.html"
    page_template = "article.html"
    paginate_by = 10
    +++
  5. Delete (or rename) your index.html, section.html and page.html templates. Templates in templates/ take precedence over the theme’s, so if you keep them you’ll keep seeing your own HTML instead of the theme’s design.

Note: if you keep xuan’s copy button (show_copy_button = true), it only appears on code blocks that specify a language, for example ```bash instead of just ```. This site replaces it with its own click-to-copy (see “Customizing part of the theme”).

Home page, archive and tags

With the theme, the home page shows the content of content/_index.md, so create that file with whatever you want to appear at /:

+++
title = "Home"
+++

## Welcome to my site

Check out the [blog](@/blog/_index.md) for the latest posts.

xuan also comes with an archive page listing every post grouped by year. To enable it, create content/archive/_index.md:

+++
title = "Archive"
template = "archive.html"
sort_by = "date"
+++

For tags, the configuration from step 3 already declares the tags taxonomy (taxonomies = [{ name = "tags", feed = true }]). Then assign them to each post in its front matter, under [taxonomies]:

+++
title = "My first post"
date = 2026-10-07

[taxonomies]
tags = ["zola", "arch-linux"]
+++

Zola generates /tags/ with the list of tags and a page for each one. The archive is already in the menu thanks to the [extra.nav] links from step 3. Tags aren’t, because each post already shows its tags as clickable badges. If you also want a “Tags” entry in the menu, add it to links in [extra.nav]:

{ url = "/tags", name = "Tags" },

Top bar options

Besides the menu links, the [extra.nav] section of zola.toml controls which buttons appear on the right side of the top bar:

[extra.nav]
# Feed button (RSS/Atom)
show_feed = true
# Light/dark theme button
show_theme_switcher = false
# Button linking to the website's source code (uses source_url from [extra])
show_repo = false

Without the theme button, the site follows the visitor’s system setting (light or dark), which the theme detects automatically.

The feed button only appears if Zola generates a feed, which is controlled by generate_feeds at the top of zola.toml. With it set to false, Zola doesn’t create atom.xml and the button disappears even though show_feed = true:

generate_feeds = false

If you want a feed again later, set it back to true. The feed = true option in taxonomies adds a feed for each tag, but only when feeds are enabled.

Accent color and post buttons

The theme’s main color is set in the [extra] section of zola.toml, with one value for the light theme and another for the dark one:

[extra]
accent_color = "#9ca0fc"
accent_color_dark = "#737cff"

It’s used for links, the active menu button, tags, the “Copied!” label on code blocks and the browser’s theme-color (the bar color on mobile). The page background is derived from it too: the theme mixes the accent with white (light theme) or black (dark theme), so changing the accent also tints the background.

Any other color (--bg-color, --fg-color, etc.) is a CSS variable defined in themes/xuan/sass/_variables.scss, and you can override it in sass/custom.scss (see “Customizing part of the theme”).

In the same [extra] section you can choose which buttons appear in each post’s quick actions (the floating buttons in the corner), for example:

[extra]
# Button listing the posts that link to the current one
show_backlinks = true

By default, xuan’s footer shows a row of link buttons (a “Blog” badge), the copyright line and a “Powered by Zola and Xuan” line. All of it is controlled from the [extra.footer] section of zola.toml:

[extra.footer]
# Removes the link badges above the copyright line
links = []
# Hides the "Powered by Zola and Xuan" line
show_powered_by = false
# Hides the "© site name, year" line
show_copyright = false

The footer is the same on every page, so these changes apply to the whole site, not only the home page.

To add a link to your GitHub (or any other social network) as an icon button in the footer, use socials in the same section. icon is a URL-encoded SVG. The theme’s example config (themes/xuan/zola.toml) already includes the GitHub one, so you can copy it from there and just change the url:

[extra.footer]
socials = [
    { url = "https://github.com/slvdr510", name = "GitHub", icon = "%3Csvg role='img' viewBox='0 0 24 24' ..." },
]

For other networks, get the SVG from simpleicons.org and encode it with yoksel.github.io/url-encoder, setting “external quotes” to double. That’s how the YouTube and X buttons were added next to the GitHub one: their SVGs from Simple Icons, encoded, as new entries in socials:

[extra.footer]
socials = [
    { url = "https://github.com/slvdr510", name = "GitHub", icon = "%3Csvg role='img' viewBox='0 0 24 24' ..." },
    { url = "https://www.youtube.com/@slvdr510", name = "YouTube", icon = "%3Csvg role='img' viewBox='0 0 24 24' ..." },
    { url = "https://x.com/slvdr510", name = "X", icon = "%3Csvg role='img' viewBox='0 0 24 24' ..." },
]

The buttons appear in the same order as in the list.

Using a language other than English

xuan’s interface text (dates, “minutes read”, menu names, etc.) only comes in English and Chinese. To use another language, set it in zola.toml (for example default_language = "es") and create i18n/es.toml in your project root, not inside the theme. Copy themes/xuan/i18n/en.toml and translate the values. The menu names (Blog, Archive, Tags) are keys translated in that file too.

Customizing part of the theme

If you want to change something the theme doesn’t let you configure, copy that specific template from the theme into your templates/ folder, keeping the same path, and edit it. Zola will use your copy instead of the theme’s.

For example, to make the top bar show only the home icon without the site name:

mkdir -p templates/partials
cp themes/xuan/templates/partials/nav.html templates/partials/nav.html

In templates/partials/nav.html, inside <li id="home">, delete the line with config.title and move the title into an aria-label, so screen readers still announce the link. Also give the link the theme’s circle class, the one its icon-only buttons (like the theme switcher) use, so it gets the same padding on every side. This is how the <li id="home"> block ends up:

<li id="home">
    <a href="{{ get_url(path='/', lang=lang) }}" {% if current_url | default(value="/" ) |
        trim_end(pat="/" ) | safe==get_url(path="/" , lang=lang) | trim_end(pat='/' ) | safe
        -%} class="circle active" {%- else %} class="circle" {%- endif %} aria-label="{{ config.title }}">
        <i class="icon"></i>
    </a>
</li>

The circle class isn’t enough on its own: the theme leaves a margin to the right of the icon (to separate it from the text that’s no longer there), and on narrow screens it stretches the link to the full width. To fix both, create sass/custom.scss:

// Round home button with only the icon (see templates/partials/nav.html).
// The selectors repeat the theme's (themes/xuan/sass/_nav.scss) so they have
// the same specificity and win by being loaded later.
#site-nav nav #home {
    justify-content: center;

    a {
        flex: 0 0 auto;
    }

    a .icon {
        margin-inline-end: 0;
    }
}

Zola compiles it to custom.css, and styles = ["custom.css"] in the [extra] section of zola.toml (already in the configuration from step 3) makes the theme load it after its own styles. If you use a shorter selector, such as #home .icon, it will be less specific than the theme’s and won’t apply.

In the same copy of nav.html you can also remove the “Skip to Main Content” link. It’s a link the theme puts at the start of the top bar and only shows when it gets keyboard focus (for example, pressing Tab right after the page loads), so keyboard and screen reader users can jump past the menu. With a menu as short as this one it adds little, so this site removes it. Delete these lines, right after <nav>:

<a href="#main-content" tabindex="0">
    {{ <xuan.translate key={"skip_to_content"} default={"Skip to Main Content"} language_strings={language_strings} lang={lang} /> }}
</a>

Keep in mind that when you update the theme (git pull inside themes/xuan), any changes it brings to that template won’t apply, because your copy is used instead. Compare it with the new version if something stops working.

You can also replace a theme feature with your own. xuan’s copy button sits in a bar above each code block, along with the block’s language (shellscript, plain…). On this site the bar is gone: hovering over a code block shows a “Click to copy” label, and clicking anywhere on the block copies it.

First, disable the theme’s button and load your own script in the [extra] section of zola.toml:

[extra]
show_copy_button = false
# Files from static/
scripts = ["copy-code.js"]

Then create static/copy-code.js:

// Copies a code block when you click anywhere on it
document.addEventListener("click", async (event) => {
    const block = event.target.closest("pre.giallo");
    if (!block || !navigator.clipboard) return;

    // If the user is selecting text by hand, don't copy the whole block
    if (window.getSelection().toString()) return;

    // giallo.js wraps each block in a .pre-container, which holds the label
    const container = block.closest(".pre-container") || block;

    try {
        await navigator.clipboard.writeText(block.querySelector("code").innerText);
        container.dataset.copyStatus = "Copied!";
    } catch {
        container.dataset.copyStatus = "Copy failed";
    }

    clearTimeout(container.copyTimer);
    container.copyTimer = setTimeout(() => delete container.dataset.copyStatus, 1500);
});

And draw the label in sass/custom.scss:

.pre-container {
    position: relative;

    pre.giallo {
        cursor: pointer;
        // The theme leaves the top corners square for its bar, which is gone
        border-radius: var(--rounded-corner);
    }

    &:hover::after,
    &[data-copy-status]::after {
        content: "Click to copy";
        position: absolute;
        inset-block-start: 0.5rem;
        inset-inline-end: 0.5rem;
        // The label ignores clicks, so they always reach the code block
        pointer-events: none;
        border-radius: var(--rounded-corner-small);
        // The theme's background and text colors, which switch with light/dark mode like the code theme
        background-color: var(--glass-bg);
        backdrop-filter: blur(4px);
        padding: 0.25rem 0.5rem;
        // Fixed width (that of "Click to copy") and centered text, so the bubble keeps its size when it switches to "Copied!"
        box-sizing: content-box;
        inline-size: 13ch;
        text-align: center;
        white-space: nowrap;
        color: var(--fg-color);
        font-weight: bold;
        font-size: var(--font-size-x-small);
        line-height: 1.2;
    }

    // After clicking, the label shows the result ("Copied!") for 1.5 seconds
    &[data-copy-status]::after {
        content: attr(data-copy-status);
        background-color: var(--accent-color);
        color: var(--contrast-color);
    }
}

The clipboard only works on secure pages (https://, or localhost/127.0.0.1 while developing), and selecting part of a block by hand still works, because the script doesn’t copy while there’s a text selection.

sass/custom.scss is also the place for small visual tweaks. For example, xuan draws the separators you write with --- in Markdown as dashed lines. To make them continuous:

// The theme uses a repeating-linear-gradient in themes/xuan/sass/_typography.scss
hr {
    background: var(--fg-muted-3);
}

The width of the central column is a CSS variable too. The theme sets it to 45rem (about 720px). This site makes the column take half of the window on large screens, and the full width when the window is narrow (for example, snapped to half of the screen):

// The theme's value is in themes/xuan/sass/_variables.scss
:root {
    --container-width: max(50vw, 60rem);
}

It works because the variable is a maximum width: the theme gives the content width: calc(100% - 2rem) and limits it with max-width: var(--container-width). On a large window, 50vw (half of the window) wins. On a narrow one, the maximum never drops below 60rem (960px), so if the window is narrower than that, the content just fills it, leaving only a small margin on each side.

The theme also leaves a large empty gap between the top bar and each post’s header (date, title and tags): the top margin of main (4.25rem) plus the top margin of #heading (2rem). To reduce it only on post pages:

main:has(#heading) {
    margin-block-start: 2rem;
}

#heading {
    margin-block-start: 0;
    // More space between the "minutes read" / tags line and the post content (the theme uses 1rem)
    margin-block-end: 4rem;
}

Posts with a banner image aren’t affected, because the theme’s rule for them (#banner-container + #heading) is more specific.

Your own favicon

By default the browser tab shows xuan’s icon: the theme links favicon.png and apple-touch-icon.png from themes/xuan/static/. To use your own icon, put it in your static/ folder. Zola copies everything in static/ to the root of the site, so static/favicon.ico is served at /favicon.ico. A file left in the project root isn’t published.

mv my-icon.ico static/favicon.ico

The theme writes its icon links in templates/partials/favicon.html, so override that template with your own copy. You don’t need to copy the theme’s version first: replace its contents with a single line.

<link rel="icon" type="image/x-icon" href="/favicon.ico" />

The path is root-relative (/favicon.ico) instead of get_url(path='favicon.ico'), so the icon loads from whichever domain serves the page. The scripts/relative_links.sh script from step 5 would make it relative anyway, but this way it also works without that script.

Some things to keep in mind:

Images in posts

To add images to a post, create the post as a folder with an index.md instead of a single .md file, and put the images next to it. Zola calls these colocated assets and copies them along with the post. This post, for example, lives in content/blog/zola_init/index.md:

content/blog/
└── my-post/
    ├── index.md     # The post (same front matter as before)
    └── diagram.png  # Its images

Then reference each image by its file name, with standard Markdown:

![Description of the image](diagram.png)

The text in brackets is the alt text: screen readers read it, and browsers show it if the image fails to load. The post’s URL doesn’t change when you turn it into a folder, because Zola takes it from the folder name.

For images shared by several posts (a logo, for example), put them in static/ instead, such as static/images/logo.png, and reference them with a leading slash: ![Logo](/images/logo.png).

Keep the images light: large screenshots or photos are worth resizing or converting to .webp before adding them, so pages load fast. Also use simple file names, in lowercase and with hyphens instead of spaces or symbols.

Opening images full size

By default, xuan slightly enlarges images when you hover over them, but clicking does nothing. On this site the hover effect is gone, and clicking an image opens it over the page (a lightbox):

The script is static/lightbox.js, loaded with scripts = ["copy-code.js", "lightbox.js"] in zola.toml:

(() => {
    let overlay, image;

    function open(src, alt) {
        overlay = document.createElement("div");
        overlay.className = "lightbox";
        overlay.setAttribute("role", "dialog");
        overlay.setAttribute("aria-modal", "true");
        overlay.setAttribute("aria-label", alt || "Image");

        const close = document.createElement("button");
        close.className = "lightbox-close";
        close.setAttribute("aria-label", "Close");
        close.textContent = "×";
        close.addEventListener("click", hide);

        image = document.createElement("img");
        image.src = src;
        image.alt = alt;
        image.addEventListener("click", toggleZoom);

        overlay.addEventListener("click", (event) => {
            if (event.target === overlay) hide();
        });

        overlay.append(close, image);
        document.body.append(overlay);
        document.documentElement.classList.add("lightbox-open");
        close.focus();
    }

    function hide() {
        overlay?.remove();
        overlay = image = null;
        document.documentElement.classList.remove("lightbox-open");
    }

    function toggleZoom(event) {
        if (overlay.classList.contains("zoomed")) {
            overlay.classList.remove("zoomed");
            image.style.width = "";
            return;
        }

        // Clicked point, relative to the image, to keep it in view after zooming
        const rect = image.getBoundingClientRect();
        const ratioX = (event.clientX - rect.left) / rect.width;
        const ratioY = (event.clientY - rect.top) / rect.height;

        // If the image was scaled down, zoom to its real size; if it already was, double it
        const fitsAtRealSize = rect.width >= image.naturalWidth;
        const width = fitsAtRealSize ? image.naturalWidth * 2 : image.naturalWidth;

        overlay.classList.add("zoomed");
        image.style.width = `${width}px`;

        const height = width * (image.naturalHeight / image.naturalWidth);
        overlay.scrollLeft = ratioX * width - event.clientX;
        overlay.scrollTop = ratioY * height - event.clientY;
    }

    document.addEventListener("click", (event) => {
        const img = event.target.closest("#main-content article img");
        // Images that are already links keep working as links
        if (!img || img.closest("a") || img.classList.contains("emoji")) return;
        open(img.currentSrc || img.src, img.alt);
    });

    document.addEventListener("keydown", (event) => {
        if (event.key === "Escape" && overlay) hide();
    });
})();

And its styles go in sass/custom.scss. The first rule removes the theme’s hover effect (from themes/xuan/sass/_media.scss); it needs the #main-content ID to be more specific than the theme’s rule:

#main-content article img:hover {
    position: static;
    transform: none;
    box-shadow: none;
}

.lightbox {
    display: flex;
    position: fixed;
    inset: 0;
    z-index: 1000;
    background-color: rgb(0 0 0 / 0.85);
    padding: 1rem;
    overflow: auto;

    img {
        // margin: auto centers the image without cutting off its top/left
        // when the zoomed image is bigger than the window
        margin: auto;
        max-width: 100%;
        max-height: 100%;
        cursor: zoom-in;
    }

    // No theme hover effect inside the lightbox either
    img:hover {
        position: static;
        transform: none;
        box-shadow: none;
    }

    &.zoomed img {
        max-width: none;
        max-height: none;
        cursor: zoom-out;
    }
}

.lightbox-close {
    position: fixed;
    inset-block-start: 1rem;
    inset-inline-end: 1rem;
    z-index: 1;
    cursor: pointer;
    border: none;
    border-radius: 999px;
    background-color: rgb(255 255 255 / 0.15);
    width: 2.5rem;
    height: 2.5rem;
    color: white;
    font-size: 1.5rem;
    line-height: 1;

    &:hover {
        background-color: rgb(255 255 255 / 0.3);
    }
}

// The page behind doesn't scroll while the lightbox is open
.lightbox-open {
    overflow: hidden;
}

Notes and warnings in posts

To highlight a note or a warning inside a post you don’t need a shortcode: Zola supports GitHub-style alerts natively. Enable them in zola.toml:

[markdown]
github_alerts = true

Then write a blockquote whose first line is the alert type:

> [!NOTE]
> The first three values must have a 0 before them, like the following ones.

There are five types: [!NOTE], [!TIP], [!IMPORTANT], [!WARNING] and [!CAUTION]. Zola only adds a markdown-alert-* class to the blockquote; the colors and icons come from the theme (xuan styles them in themes/xuan/sass/_alerts.scss). Leave a blank line before and after the block, or Markdown may merge it with the surrounding paragraph.