zona

[zona](https://sr.ht/~ficd/zona) is an _opinionated_ static site generator written in Python. From a structured directory of Markdown content, zona builds a simple static website. It's designed to get out of your way and let you focus on writing. **What do I mean by opinionated?** I built zona primarily for myself. I've tried making it flexible by exposing as many variables as possible to the template engine. However, if you're looking for something stable, complete, and fully configurable, zona may not be for you. If you want a minimal Markdown blog and are comfortable with modifying `jinja2` templates and CSS, then you're in luck. **Note:** This project is in early development, there are no versioned releases yet, and breaking changes are likely. Versioned releases will be made and zona will be published to PyPI once it's stable. zona was previously implemented in Go; I decided to rewrite the project in Python. If you're interested in seeing the previous codebase (which is feature incomplete), visit the [~ficd/zona-go](https://git.sr.ht/~ficd/zona-go) repository. For an example of a website built with zona, please see [ficd.ca](https://ficd.ca). - [Features](#features) - [Installation](#installation) - [Usage](#usage) - [Getting Started](#getting-started) - [Site Layout](#site-layout) - [Internal Link Resolution](#internal-link-resolution) - [Syntax Highlighting](#syntax-highlighting) - [Image Labels](#image-labels) - [Frontmatter](#frontmatter) - [Post List](#post-list) - [Configuration](#configuration) - [Sitemap](#sitemap) - [Ignore List](#ignore-list) - [Drafts](#drafts) ## Features - Live preview server with automatic rebuilding. - `jinja2` template support with sensible defaults included. - Basic page, blog post, post list. - Glob ignore. - YAML frontmatter. - Easily configurable sitemap header. - Site footer written in Markdown. - Smart site layout discovery. - Blog posts are automatically discovered and rendered accordingly (can be overridden in frontmatter). - Extended Markdown renderer: - Smart internal link resolution. - Syntax highlighting. - Includes Kakoune syntax and [Ashen] highlighting. - [Image labels](#image-labels). - Many `python-markdown` extensions enabled, including footnotes, tables, abbreviations, etc. ## Installation zona is not yet packaged on PyPI. You may use `uv` to install it from this repository: ```sh uv tool install 'git+https://git.sr.ht/~ficd/zona' ``` ## Usage _Note: you may provide the `--help` option to any subcommand to see the available options and arguments._ ### Getting Started To set up a new website, create a new directory and run `zona init` inside of it. This creates the required directory structure and writes the default configuration file. The default templates and default stylesheet are also written. To build the website, run `zona build`. The project root is discovered according to the location of `config.yml`. By default, the output directory is called `public`, and saved inside the root directory. To start a live preview session, execute `zona serve`. The server will run until it's killed by the user, and the website is rebuilt if any source files are modified. _Note: if you change `config.yml` or any templates, you will need to restart the preview server_. ### Site Layout The following demonstrates a simple zona project layout: ``` config.yml content/ templates/ public/ ``` The **root** of the zona **project** _must_ contain the configuration file, `config.yml`, and a directory called `content`. A directory called `templates` is optional, and prioritized if it exists. `public` is the built site output — it's recommended to add this path to your `.gitignore`. The `content` directory is the **root of the website**. Think of it as the **content root**. For example, suppose your website is hosted at `example.com`. `content/blog/index.md` corresponds to `example.com/blog`, `content/blog/my-post.md` becomes `example.com/blog/my-post`, etc. - Internal links are resolved **relative to the `content` directory.** - Templates are resolved relative to the `template` directory. Markdown files inside a certain directory (`content/blog` by default) are automatically treated as _blog posts_. This means they are rendered with the `page` template, and included in the `post_list`, which can be included in your site using the `post_list` template. ### Internal Link Resolution When zona encounters links in Markdown documents, it attempts to resolve them as internal links. Links beginning with `/` are resolved relative to the content root; otherwise, they are resolved relative to the Markdown file. If the link resolves to an existing file that is part of the website, it's replaced with an appropriate web-server-friendly link. Otherwise, the link isn't changed. For example, suppose the file `blog/post1.md` has a link `./post2.md`. The HTML output will contain the link `/blog/post2` (which corresponds to `/blog/post2/index.html`). Link resolution is applied to _all_ internal links, including those pointing to static resources like images. Links are only modified if they point to a real file that's not included in the ignore list. ### Syntax Highlighting zona uses [Pygments] to provide syntax highlighting for fenced code blocks. The following Pygments plugins are included: - [pygments-kakoune](https://git.sr.ht/~ficd/pygments-kakoune) - A lexer providing for highlighting Kakoune code. Available under the `kak` and `kakrc` aliases. - [pygments-ashen](https://git.sr.ht/~ficd/ashen/tree/main/item/pygments/README.md) - An implementation of the [Ashen](https://git.sr.ht/~ficd/ashen) theme for Pygments. If you want to use any external Pygments styles or lexers, they must be available in zona's Python environment. For example, you can give zona access to [Catppucin](https://github.com/catppuccin/python): ```yaml # config.yml markdown: syntax_highlighting: theme: catppucin-mocha ``` Then, run zona with the following `uv` command: ```sh uvx --with catppucin zona build ``` Inline syntax highlighting is also provided via a `python-markdown` extension. If you prefix inline code with a shebang followed by the language identifier, it will be highlighted. For example: ``` `#!python print(f"I love {foobar}!", end="")` will be rendered as `print(f"I love {foobar}!", end="")` (the #!lang is stripped) ``` ### Image Labels A feature unique to zona is **image labels**. They make it easy to annotate images in your Markdown documents. The alt text Markdown element is rendered as the label — with support for inline Markdown. Consider this example: ```markdown ![This **image** has _markup_.](static/markdown.png) ``` The above results in the following HTML: ```html
This image has markup.
``` The `image-container` class is provided as a convenience for styling. The default stylesheet centers the label under the image. Note: _links_ inside image captions are not currently supported. I am looking into a solution. ### Frontmatter YAML frontmatter can be used to configure the metadata of documents. All of them are optional. `none` is used when the option is unset. The following options are available: | Key | Type & Default | Description | | ------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------ | | `title` | `str` = title-cased filename. | Title of the page. | | `date` | Date string = file modified time. | Displayed on blog posts and used for post_list sorting. | | `show_title` | `bool` = `true` | Whether `metadata.title` should be included in the template. | | `header` | `bool` = `true` | Whether the header sitemap should be rendered. | | `footer` | `bool` = `true` | Whether the footer should be rendered. | | `template` | `str \| none` = `none` | Template to use for this page. Relative to `templates/`, `.html` extension optional. | | `post` | `bool \| none` = `none` | Whether this page is a **post**. `true`/`false` is _absolute_. Leave it unset for automatic detection. | | `draft` | `bool` = `false` | Whether this page is a draft. See [drafts](#drafts) for more. | **Note**: you can specify the date in any format that can be parsed by [`python-dateutil`](https://pypi.org/project/python-dateutil/). ### Post List Suppose you want `example.com/blog` to be a _post list_ page, and you want `example.com/blog/my-post` to be a post. You would first create `content/blog/index.md` and add the following frontmatter: ```markdown --- title: Blog post: false template: post_list --- Welcome to my blog! Please find a list of my posts below. ``` Setting `post: false` is necessary because, by default, all documents inside `content/blog` are considered to be posts unless explicitly disabled in the frontmatter. We don't want the post list to list _itself_ as a post. Then, you'd create `content/blog/my-post.md` and populate it: ```markdown --- title: My First Post date: July 5, 2025 --- ``` Because `my-post` is inside the `blog` directory, `post: true` is implied. If you wanted to put it somewhere outside `blog`, you would need to set `post: true` for it to be included in the post list. ## Configuration Zona is configured in YAML format. The configuration file is called `config.yml` and it **must** be located in the root of the project — in the same directory as `content` and `templates`. Your configuration will be merged with the defaults. `zona init` also writes a copy of the default configuration to the correct location. If it exists, you'll be prompted before overwriting it. **Note:** Currently, not every configuration value is actually used. Only the useful settings are listed here. Please see the default configuration: ```yaml sitemap: Home: / ignore: - .marksman.toml markdown: image_labels: true tab_length: 2 syntax_highlighting: enabled: true theme: ashen wrap: false blog: dir: blog ``` | Name | Description | | -------------------------------------- | --------------------------------------------------------------------------------------------- | | `sitemap` | Sitemap dictionary. See [Sitemap](#sitemap). | | `ignore` | List of paths to ignore. See [Ignore List](#ignore-list). | | `markdown.tab_length` | How many spaces should be considered an indentation level. | | `markdown.syntax_highlighting.enabled` | Whether code should be highlighted. | | `markdown.syntax_highlighting.theme` | [Pygments] style for highlighting. | | `markdown.syntax_highlighting.wrap` | Whether the resulting code block should be word wrapped. | | `blog.dir` | Name of a directory relative to `content/` whose children are automatically considered posts. | ### Sitemap You can define a sitemap in the configuration file. This is a list of links that will be rendered at the top of every page. The `sitemap` is a dictionary of `string` to `string` pairs, where each key is the displayed text of the link, and the value if the `href`. Consider this example: ```yaml sitemap: Home: / About: /about Blog: /blog Git: https://git.sr.ht/~ficd ``` ### Ignore List You can set a list of glob patterns in the [configuration](#configuration) that should be ignored by zona. This is useful because zona makes a copy of _every_ file it encounters inside the `content` directory, regardless of its type. The paths must be relative to the `content` directory. ### Drafts zona allows you to begin writing content without including it in the final build output. If you set `draft: true` in a page's frontmatter, it will be marked as a draft. Drafts are completely excluded from `zona build` and `zona serve` unless the `--draft` flag is specified. [Ashen]: https://sr.ht/~ficd/ashen [Pygments]: https://pygments.org/