Compare commits

...

25 commits

Author SHA1 Message Date
70887156fb wip: generate RSS feed 2025-07-14 21:25:16 -04:00
1f2b736815 feat: merge user templates with defaults 2025-07-14 21:24:22 -04:00
c489a6f076 added post-nav to posts 2025-07-14 20:15:40 -04:00
933210c93b fixed crash on missing templates dir when starting server 2025-07-14 19:19:58 -04:00
404e951651 added smooth scroll to default stylesheet 2025-07-14 18:57:51 -04:00
24919171ad fixed structure of page date in templates 2025-07-14 16:34:17 -04:00
de86a92928 added title styling to default stylesheet 2025-07-14 16:24:28 -04:00
2bc12ff2ed refactored title in templates
now has "title" class for easier styling
2025-07-14 16:24:14 -04:00
07152a3746 feat: ignore frontmatter option 2025-07-14 16:11:47 -04:00
27178dc6d8 updated formatter config 2025-07-14 16:06:24 -04:00
04948a9373 formatted python files 2025-07-14 16:04:05 -04:00
faac8f63bb major updates to default stylesheet 2025-07-14 15:00:54 -04:00
e28a206067 update publish workflow to use based-alpine 2025-07-13 23:42:51 -04:00
8b849fd2a0 ci: added pypi release workflow 2025-07-13 22:10:37 -04:00
e69dd80875 updated just recipes 2025-07-13 21:33:36 -04:00
db8318622c fixed license identifier in pyproject 2025-07-13 21:32:24 -04:00
0e32024147 update metadata for release 2025-07-13 21:29:15 -04:00
a789d1f64e changed license to BSD-3 2025-07-13 21:28:00 -04:00
46934e502f update readme 2025-07-13 21:25:20 -04:00
0166e82cce updated readme 2025-07-13 18:24:56 -04:00
10d1772a2d added config option for preview scroll tolerance 2025-07-13 18:12:11 -04:00
fe0f338803 added auto-scrolling for preview 2025-07-13 17:20:59 -04:00
1c64d1f431 make injection script template a data file 2025-07-13 16:54:04 -04:00
585a987c3f updated default css 2025-07-13 16:04:17 -04:00
dacea2756a fix: live preview rebuilds no longer crash the server
When cleaning the previous build, we deleted the entire directory, which
caused problems. Now, we only clean the output dir's children, but leave
the dir itself intact.
2025-07-13 03:16:55 -04:00
23 changed files with 859 additions and 394 deletions

View file

@ -0,0 +1,17 @@
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: based-alpine
steps:
- uses: actions/checkout@v4
- name: build
run: |
uv sync
uv build
- name: publish
run: |
uv publish --token ${{ secrets.PYPI_TOKEN }}

33
LICENSE
View file

@ -1,13 +1,24 @@
Copyright (c) 2025 Daniel Fichtinger <daniel@ficd.ca> Copyright (c) 2025 Daniel Fichtinger <daniel@ficd.sh>
Permission to use, copy, modify, and distribute this software for any Redistribution and use in source and binary forms, with or without
purpose with or without fee is hereby granted, provided that the above modification, are permitted provided that the following conditions
copyright notice and this permission notice appear in all copies. are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the author nor the names of its contributors may
be used to endorse or promote products derived from this software
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS "AS IS" AND
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
SUCH DAMAGE.

348
README.md
View file

@ -1,27 +1,19 @@
<h1>zona</h1> <h1>zona</h1>
[zona](https://sr.ht/~ficd/zona) is an _opinionated_ static site generator [zona](https://git.ficd.sh/ficd/zona) is an _opinionated_ static site generator
written in Python. From a structured directory of Markdown content, zona written in Python. From a structured directory of Markdown content, zona builds
builds a simple static website. It's designed to get out of your way and a simple static website. It's designed to get out of your way and let you focus
let you focus on writing. on writing.
**What do I mean by opinionated?** I built zona primarily for myself. I've **What do I mean by opinionated?** I built zona primarily for myself. I've tried
tried making it flexible by exposing as many variables as possible to the making it flexible by exposing as many variables as possible to the template
template engine. However, if you're looking for something stable, engine. However, if you're looking for something stable, complete, and fully
complete, and fully configurable, zona may not be for you. If you want a configurable, zona may not be for you. If you want a minimal Markdown blog and
minimal Markdown blog and are comfortable with modifying `jinja2` are comfortable with modifying `jinja2` templates and CSS, then you're in luck.
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 For an example of a website built with zona, please see
[ficd.sh](https://ficd.sh). [ficd.sh](https://ficd.sh). For a list of known problems, see
[Known Problems](#known-problems).
<!--toc:start--> <!--toc:start-->
@ -31,6 +23,7 @@ For an example of a website built with zona, please see
- [Getting Started](#getting-started) - [Getting Started](#getting-started)
- [Building](#building) - [Building](#building)
- [Live Preview](#live-preview) - [Live Preview](#live-preview)
- [Live Reload](#live-reload)
- [How It Works](#how-it-works) - [How It Works](#how-it-works)
- [Site Layout](#site-layout) - [Site Layout](#site-layout)
- [Templates](#templates) - [Templates](#templates)
@ -45,6 +38,7 @@ For an example of a website built with zona, please see
- [Sitemap](#sitemap) - [Sitemap](#sitemap)
- [Ignore List](#ignore-list) - [Ignore List](#ignore-list)
- [Drafts](#drafts) - [Drafts](#drafts)
- [Known Problems](#known-problems)
<!--toc:end--> <!--toc:end-->
@ -60,24 +54,29 @@ For an example of a website built with zona, please see
- Easily configurable sitemap header. - Easily configurable sitemap header.
- Site footer written in Markdown. - Site footer written in Markdown.
- Smart site layout discovery. - Smart site layout discovery.
- Blog posts are automatically discovered and rendered accordingly (can - Blog posts are automatically discovered and rendered accordingly (can be
be overridden in frontmatter). overridden in frontmatter).
- Extended Markdown renderer: - Extended Markdown renderer:
- Smart internal link resolution. - Smart internal link resolution.
- Syntax highlighting. - Syntax highlighting.
- Includes Kakoune syntax and [Ashen] highlighting. - Includes Kakoune syntax and [Ashen] highlighting.
- [Image labels](#image-labels). - [Image labels](#image-labels).
- Many `python-markdown` extensions enabled, including footnotes, - Many `python-markdown` extensions enabled, including footnotes, tables,
tables, abbreviations, etc. abbreviations, etc.
- LaTeX support. - LaTeX support.
## Installation ## Installation
zona is not yet packaged on PyPI. You may use `uv` to install it from this Zona can be installed as a Python package. Instructions for
repository: [`uv`](https://docs.astral.sh/uv/) are provided.
```sh ```sh
uv tool install 'git+https://git.sr.ht/~ficd/zona' # install latest release
uv tool install zona
# install bleeding edge from git
uv tool install 'git+https://git.ficd.sh/ficd/zona'
# you can also run without installation
uvx zona build --help
``` ```
## Usage ## Usage
@ -87,71 +86,78 @@ available options and arguments._
### Getting Started ### Getting Started
To set up a new website, create a new directory and run `zona init` inside To set up a new website, create a new directory and run `zona init` inside of
of it. This creates the required directory structure and writes the it. This creates the required directory structure and writes the default
default configuration file. The default templates and default stylesheet configuration file. The default templates and default stylesheet are also
are also written. written.
### Building ### Building
To build the website, run `zona build`. The project root is discovered To build the website, run `zona build`. The project root is discovered according
according to the location of `config.yml`. By default, the output to the location of `config.yml`. By default, the output directory is called
directory is called `public`, and saved inside the root directory. `public`, and saved inside the root directory.
If you don't want discovery, you can specify the project root as the first If you don't want discovery, you can specify the project root as the first
argument to `zona build`. You may specify a path for the output using the argument to `zona build`. You may specify a path for the output using the
`--output/-o` flag. The `--draft/-d` flag includes draft posts in the `--output/-o` flag. The `--draft/-d` flag includes draft posts in the output.
output.
_Note: the previous build is _not_ cleaned before the new site is built. _Note: the previous build is _not_ cleaned before the new site is built. If
If you've deleted some pages, you may need to remove the output directory you've deleted some pages, you may need to remove the output directory before
before rebuilding._ rebuilding._
### Live Preview ### Live Preview
To make the writing process as frictionless as possible, zona ships with a To make the writing process as frictionless as possible, zona ships with a live
live preview server. It spins up an HTTP server, meaning that internal preview server. It spins up an HTTP server, meaning that internal links work
links work properly (this is not the case if you simply open the `.html` properly (this is not the case if you simply open the `.html` files in your
files in your browser.) browser.)
Additionally, the server watches for changes to all source files, and Additionally, the server watches for changes to all source files, and rebuilds
rebuilds the website when they're modified. _Note: the entire website is the website when they're modified. _Note: the entire website is rebuilt — this
rebuilt — this ensures that links are properly resolved._ ensures that links are properly resolved._
Optionally, live reloading of the browser is also provided. With this Drafts are enabled by default in live preview. Use `--final/-f` to disable them.
feature (enabled by default), your browser will automatically refresh open By default, the build outputs to a temporary directory. Use `-o/--output` to
pages whenever the site is rebuilt. The live reloading requires JavaScript override this.
support from the browser — this is why the feature is optional.
To start a preview server, use `zona serve`. You can specify the root **Note**: if the live preview isn't working as expected, try restarting the
directory as its first argument. Use the `--host` to specify a host name server. If you change the configuration or any templates, the server must also
(`localhost` by default) and `--port/-p` to specify a port (default: be restarted. The live preview uses the same function as `zona build`
`8000`). The `--no-live-reload/-n` disables the live browser reloading internally; this means that the output is also written to disk.
(_automatic site rebuilds are not disabled_).
Drafts are enabled by default in live preview. Use `--final/-f` to disable #### Live Reload
them. By default, the build outputs to a temporary directory. Use
`-o/--output` to override this.
**Note**: if the live preview isn't working as expected, try restarting Optionally, live reloading of the browser is also provided. With this feature
the server. If you change the configuration or any templates, the server (enabled by default), your browser will automatically refresh open pages
must also be restarted. The live preview uses the same function as whenever the site is rebuilt. The live reloading requires JavaScript support
`zona build` internally; this means that the output is also written to from the browser — this is why the feature is optional.
disk.
To start a preview server, use `zona serve`. You can specify the root directory
as its first argument. Use the `--host` to specify a host name (`localhost` by
default) and `--port/-p` to specify a port (default: `8000`).
The `--live-reload/--no-live-reload` option overrides the value set in the
[config](#configuration) (`true` by default). _Automatic site rebuilds are not
affected_.
If you are scrolled to the bottom of the page in the browser, and you extend the
height of the page by adding new content, you will automatically be scrolled to
the _new_ bottom after reloading. You may tune the tolerance threshold in the
[configuration](#configuration).
#### How It Works #### How It Works
The basic idea is this: after a rebuild, the server needs to notify your The basic idea is this: after a rebuild, the server needs to notify your browser
browser to refresh the open pages. We implement this using a small amount to refresh the open pages. We implement this using a small amount of JavaScript.
of JavaScript. The server injects a tiny script into any HTML page it The server injects a tiny script into any HTML page it serves; which causes your
serves; which causes your browser to open a WebSocket connection with the browser to open a WebSocket connection with the server. When the site is
server. When the site is rebuilt, the server notifies your browser via the rebuilt, the server notifies your browser via the WebSocket, which reloads the
WebSocket, which reloads the page. page.
Unfortunately, there is no way to implement this feature without using Unfortunately, there is no way to implement this feature without using
JavaScript. **JavaScript is _only_ used for the live preview feature. The JavaScript. **JavaScript is _only_ used for the live preview feature. The script
script is injected by the server, and never written to the HTML files in is injected by the server, and never written to the HTML files in the output
the output directory.** directory.**
### Site Layout ### Site Layout
@ -164,32 +170,31 @@ templates/
public/ public/
``` ```
The **root** of the zona **project** _must_ contain the configuration The **root** of the zona **project** _must_ contain the configuration file,
file, `config.yml`, and a directory called `content`. A directory called `config.yml`, and a directory called `content`. A directory called `templates`
`templates` is optional, and prioritized if it exists. `public` is the is optional, and merged with the defaults if it exists. `public` is the built
built site output — it's recommended to add this path to your site output — it's recommended to add this path to your `.gitignore`.
`.gitignore`.
The `content` directory is the **root of the website**. Think of it as the The `content` directory is the **root of the website**. Think of it as the
**content root**. For example, suppose your website is hosted at **content root**. For example, suppose your website is hosted at `example.com`.
`example.com`. `content/blog/index.md` corresponds to `example.com/blog`, `content/blog/index.md` corresponds to `example.com/blog`,
`content/blog/my-post.md` becomes `example.com/blog/my-post`, etc. `content/blog/my-post.md` becomes `example.com/blog/my-post`, etc.
- Internal links are resolved **relative to the `content` directory.** - Internal links are resolved **relative to the `content` directory.**
- Templates are resolved relative to the `template` directory. - Templates are resolved relative to the `template` directory.
Markdown files inside a certain directory (`content/blog` by default) are Markdown files inside a certain directory (`content/blog` by default) are
automatically treated as _blog posts_. This means they are rendered with automatically treated as _blog posts_. This means they are rendered with the
the `page` template, and included in the `post_list`, which can be `page` template, and included in the `post_list`, which can be included in your
included in your site using the `post_list` template. site using the `post_list` template.
### Templates ### Templates
The `templates` directory may contain any `jinja2` template files. You may The `templates` directory may contain any `jinja2` template files. You may
modify the existing templates or create your own. To apply a certain modify the existing templates or create your own. Your templates are merged with
template to a page, set the `template` option in its the packaged defaults. To apply a certain template to a page, set the `template`
[frontmatter](#frontmatter). The following public variables are made option in its [frontmatter](#frontmatter). The following public variables are
available to the template engine: made available to the template engine:
| Name | Description | | Name | Description |
| ---------- | ------------------------------------------------------ | | ---------- | ------------------------------------------------------ |
@ -201,43 +206,40 @@ available to the template engine:
#### Markdown Footer #### Markdown Footer
The `templates` directory can contain a file called `footer.md`. If it The `templates` directory can contain a file called `footer.md`. If it exists,
exists, it's parsed and rendered into HTML, then made available to other it's parsed and rendered into HTML, then made available to other templates as
templates as the `footer` variable. If `footer.md` is missing but the `footer` variable. If `footer.md` is missing but `footer.html` exists, then
`footer.html` exists, then it's used instead. **Note: links are _not_ it's used instead. **Note: links are _not_ resolved in the footer.**
resolved in the footer.**
### Internal Link Resolution ### Internal Link Resolution
When zona encounters links in Markdown documents, it attempts to resolve When zona encounters links in Markdown documents, it attempts to resolve them as
them as internal links. Links beginning with `/` are resolved relative to internal links. Links beginning with `/` are resolved relative to the content
the content root; otherwise, they are resolved relative to the Markdown root; otherwise, they are resolved relative to the Markdown file. If the link
file. If the link resolves to an existing file that is part of the resolves to an existing file that is part of the website, it's replaced with an
website, it's replaced with an appropriate web-server-friendly link. appropriate web-server-friendly link. Otherwise, the link isn't changed.
Otherwise, the link isn't changed.
For example, suppose the file `blog/post1.md` has a link `./post2.md`. The For example, suppose the file `blog/post1.md` has a link `./post2.md`. The HTML
HTML output will contain the link `/blog/post2` (which corresponds to output will contain the link `/blog/post2` (which corresponds to
`/blog/post2/index.html`). Link resolution is applied to _all_ internal `/blog/post2/index.html`). Link resolution is applied to _all_ internal links,
links, including those pointing to static resources like images. Links are including those pointing to static resources like images. Links are only
only modified if they point to a real file that's not included in the modified if they point to a real file that's not included in the ignore list.
ignore list.
### Syntax Highlighting ### Syntax Highlighting
Zona uses [Pygments] to provide syntax highlighting for fenced code Zona uses [Pygments] to provide syntax highlighting for fenced code blocks. The
blocks. The following Pygments plugins are included: following Pygments plugins are included:
- [pygments-kakoune](https://git.sr.ht/~ficd/pygments-kakoune) - [pygments-kakoune](https://codeberg.com/ficd/pygments-kakoune)
- A lexer providing for highlighting Kakoune code. Available under the - A lexer providing for highlighting Kakoune code. Available under the `kak`
`kak` and `kakrc` aliases. and `kakrc` aliases.
- [pygments-ashen](https://git.sr.ht/~ficd/ashen/tree/main/item/pygments/README.md) - [pygments-ashen](https://codeberg.com/ficd/ashen/tree/main/item/pygments/README.md)
- An implementation of the [Ashen](https://git.sr.ht/~ficd/ashen) theme - An implementation of the [Ashen](https://codeberg.com/ficd/ashen) theme for
for Pygments. Pygments.
If you want to use any external Pygments styles or lexers, they must be 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 available in zona's Python environment. For example, you can give zona access to
access to [Catppucin](https://github.com/catppuccin/python): [Catppucin](https://github.com/catppuccin/python):
```yaml ```yaml
# config.yml # config.yml
@ -252,9 +254,9 @@ Then, run zona with the following `uv` command:
uvx --with catppucin zona build uvx --with catppucin zona build
``` ```
Inline syntax highlighting is also provided via a `python-markdown` Inline syntax highlighting is also provided via a `python-markdown` extension.
extension. If you prefix inline code with a shebang followed by the If you prefix inline code with a shebang followed by the language identifier, it
language identifier, it will be highlighted. For example: will be highlighted. For example:
``` ```
`#!python print(f"I love {foobar}!", end="")` `#!python print(f"I love {foobar}!", end="")`
@ -280,10 +282,9 @@ will be rendered as
### Image Labels ### Image Labels
A feature unique to zona is **image labels**. They make it easy to A feature unique to zona is **image labels**. They make it easy to annotate
annotate images in your Markdown documents. The alt text Markdown element images in your Markdown documents. The alt text Markdown element is rendered as
is rendered as the label — with support for inline Markdown. Consider this the label — with support for inline Markdown. Consider this example:
example:
```markdown ```markdown
![This **image** has _markup_.](static/markdown.png) ![This **image** has _markup_.](static/markdown.png)
@ -298,14 +299,14 @@ The above results in the following HTML:
``` ```
The `image-container` class is provided as a convenience for styling. The The `image-container` class is provided as a convenience for styling. The
default stylesheet centers the label under the image. Note: _links_ inside default stylesheet centers the label under the image. Note: _links_ inside image
image captions are not currently supported. I am looking into a solution. captions are not currently supported. I am looking into a solution.
### Frontmatter ### Frontmatter
YAML frontmatter can be used to configure the metadata of documents. All YAML frontmatter can be used to configure the metadata of documents. All of them
of them are optional. `none` is used when the option is unset. The are optional. `none` is used when the option is unset. The following options are
following options are available: available:
| Key | Type & Default | Description | | Key | Type & Default | Description |
| ------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------ | | ------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------ |
@ -317,6 +318,7 @@ following options are available:
| `template` | `str \| none` = `none` | Template to use for this page. Relative to `templates/`, `.html` extension optional. | | `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. | | `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. | | `draft` | `bool` = `false` | Whether this page is a draft. See [drafts](#drafts) for more. |
| `ignore` | `bool` = `false` | Whether this page should be ignored in _both_ `final` and `draft` contexts. |
| `math` | `bool` = `true` | Whether the LaTeX extension should be enabled for this page. | | `math` | `bool` = `true` | Whether the LaTeX extension should be enabled for this page. |
**Note**: you can specify the date in any format that can be parsed by **Note**: you can specify the date in any format that can be parsed by
@ -338,10 +340,9 @@ template: post_list
Welcome to my blog! Please find a list of my posts below. Welcome to my blog! Please find a list of my posts below.
``` ```
Setting `post: false` is necessary because, by default, all documents Setting `post: false` is necessary because, by default, all documents inside
inside `content/blog` are considered to be posts unless explicitly `content/blog` are considered to be posts unless explicitly disabled in the
disabled in the frontmatter. We don't want the post list to list _itself_ frontmatter. We don't want the post list to list _itself_ as a post.
as a post.
Then, you'd create `content/blog/my-post.md` and populate it: Then, you'd create `content/blog/my-post.md` and populate it:
@ -352,32 +353,32 @@ date: July 5, 2025
--- ---
``` ```
Because `my-post` is inside the `blog` directory, `post: true` is implied. Because `my-post` is inside the `blog` directory, `post: true` is implied. If
If you wanted to put it somewhere outside `blog`, you would need to set you wanted to put it somewhere outside `blog`, you would need to set
`post: true` for it to be included in the post list. `post: true` for it to be included in the post list.
## Configuration ## Configuration
Zona is configured in YAML format. The configuration file is called Zona is configured in YAML format. The configuration file is called `config.yml`
`config.yml` and it **must** be located in the root of the project — in and it **must** be located in the root of the project — in the same directory as
the same directory as `content` and `templates`. `content` and `templates`.
Your configuration will be merged with the defaults. `zona init` also Your configuration will be merged with the defaults. `zona init` also writes a
writes a copy of the default configuration to the correct location. If it copy of the default configuration to the correct location. If it exists, you'll
exists, you'll be prompted before overwriting it. be prompted before overwriting it.
**Note:** Currently, not every configuration value is actually used. Only **Note:** Currently, not every configuration value is actually used. Only the
the useful settings are listed here. useful settings are listed here.
Please see the default configuration: Please see the default configuration:
```yaml ```yaml
base_url: /
sitemap: sitemap:
Home: / Home: /
ignore: ignore:
- .marksman.toml - .marksman.toml
markdown: markdown:
image_labels: true
tab_length: 2 tab_length: 2
syntax_highlighting: syntax_highlighting:
enabled: true enabled: true
@ -385,49 +386,70 @@ markdown:
wrap: false wrap: false
links: links:
external_new_tab: true external_new_tab: true
build:
clean_output_dir: true
include_drafts: false
blog: blog:
dir: blog dir: blog
server:
reload:
enabled: true
scroll_tolerance: 100
``` ```
| Name | Description | | Name | Description |
| -------------------------------------- | --------------------------------------------------------------------------------------------- | | -------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `sitemap` | Sitemap dictionary. See [Sitemap](#sitemap). | | `sitemap` | Sitemap dictionary. See [Sitemap](#sitemap). |
| `ignore` | List of paths to ignore. See [Ignore List](#ignore-list). | | `ignore` | List of paths to ignore. See [Ignore List](#ignore-list). |
| `markdown.tab_length` | How many spaces should be considered an indentation level. | | `markdown.tab_length` | How many spaces should be considered an indentation level. |
| `markdown.syntax_highlighting.enabled` | Whether code should be highlighted. | | `markdown.syntax_highlighting.enabled` | Whether code should be highlighted. |
| `markdown.syntax_highlighting.theme` | [Pygments] style for highlighting. | | `markdown.syntax_highlighting.theme` | [Pygments] style for highlighting. |
| `markdown.syntax_highlighting.wrap` | Whether the resulting code block should be word wrapped. | | `markdown.syntax_highlighting.wrap` | Whether the resulting code block should be word wrapped. |
| `markdown.links.external_new_tab` | Whether external links should be opened in a new tab. | | `markdown.links.external_new_tab` | Whether external links should be opened in a new tab. |
| `blog.dir` | Name of a directory relative to `content/` whose children are automatically considered posts. | | `build.clean_output_dir` | Whether previous build artifacts should be cleared when building. Recommended to leave this on. |
| `build.include_drafts` | Whether drafts should be included by default. |
| `blog.dir` | Name of a directory relative to `content/` whose children are automatically considered posts. |
| `server.reload.enabled` | Whether the preview server should use [live reload](#live-preview). |
| `server.reload.scroll_tolerance` | The distance, in pixels, from the bottom to still count as "scrolled to bottom". |
### Sitemap ### Sitemap
You can define a sitemap in the configuration file. This is a list of You can define a sitemap in the configuration file. This is a list of links that
links that will be rendered at the top of every page. The `sitemap` is a will be rendered at the top of every page. The `sitemap` is a dictionary of
dictionary of `string` to `string` pairs, where each key is the displayed `string` to `string` pairs, where each key is the displayed text of the link,
text of the link, and the value if the `href`. Consider this example: and the value if the `href`. Consider this example:
```yaml ```yaml
sitemap: sitemap:
Home: / Home: /
About: /about About: /about
Blog: /blog Blog: /blog
Git: https://git.sr.ht/~ficd Git: https://git.ficd.sh/ficd
``` ```
### Ignore List ### Ignore List
You can set a list of glob patterns in the [configuration](#configuration) You can set a list of glob patterns in the [configuration](#configuration) that
that should be ignored by zona. This is useful because zona makes a copy should be ignored by zona. This is useful because zona makes a copy of _every_
of _every_ file it encounters inside the `content` directory, regardless file it encounters inside the `content` directory, regardless of its type. The
of its type. The paths must be relative to the `content` directory. paths must be relative to the `content` directory.
### Drafts ### Drafts
zona allows you to begin writing content without including it in the final zona allows you to begin writing content without including it in the final build
build output. If you set `draft: true` in a page's frontmatter, it will be output. If you set `draft: true` in a page's frontmatter, it will be marked as a
marked as a draft. Drafts are completely excluded from `zona build` and draft. Drafts are completely excluded from `zona build` and `zona serve` unless
`zona serve` unless the `--draft` flag is specified. the `--draft` flag is specified.
[Ashen]: https://sr.ht/~ficd/ashen [Ashen]: https://codeberg.com/ficd/ashen
[Pygments]: https://pygments.org/ [Pygments]: https://pygments.org/
## Known Problems
1. If the user triggers rebuilds in quick succession, the browser is sent the
reload command after the first build, even though a second build may be
underway. This results in a `404` page being served, and the user needs to
manually refresh the browser page.
**Mitigation:** Don't allow a rebuild until the browser has re-connected to
the WebSocket after the first reload.

View file

@ -2,6 +2,28 @@
default: default:
@just --list @just --list
clean:
#!/bin/sh
if [ ! -d "dist" ] && [ ! -d "__pycache__" ]; then
echo "Nothing to clean."
exit 0
fi
if [ -d "dist" ]; then
echo "Removing dist/"
rm -r dist/
fi
if [ -d "__pycache__" ]; then
echo "Removing __pycache__/"
rm -r "__pycache__"
fi
publish:
#!/bin/sh
just clean
uv build
export UV_PUBLISH_TOKEN="$(pass show pypi)"
uv publish
format: format:
uv run ruff format uv run ruff format

View file

@ -1,14 +1,17 @@
[project] [project]
name = "zona" name = "zona"
version = "0.1.0" version = "1.0.0"
description = "Static site generator" description = "Opinionated static site generator."
license = "BSD-3-Clause "
license-files = ["LICENSE"]
readme = "README.md" readme = "README.md"
authors = [ authors = [
{ name = "Daniel Fichtinger", email = "daniel@ficd.ca" }, { name = "Daniel Fichtinger", email = "daniel@ficd.sh" },
] ]
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"dacite>=1.9.2", "dacite>=1.9.2",
"feedgen>=1.0.0",
"jinja2>=3.1.6", "jinja2>=3.1.6",
"l2m4m>=1.0.4", "l2m4m>=1.0.4",
"markdown>=3.8.2", "markdown>=3.8.2",
@ -24,6 +27,9 @@ dependencies = [
"websockets>=15.0.1", "websockets>=15.0.1",
] ]
[project.urls]
Repository = "https://git.ficd.sh/ficd/zona"
[project.scripts] [project.scripts]
zona = "zona.cli:main" zona = "zona.cli:main"
@ -52,12 +58,12 @@ reportUnusedCallResult = false
reportCallInDefaultInitializer = false reportCallInDefaultInitializer = false
enableTypeIgnoreComments = true enableTypeIgnoreComments = true
reportIgnoreCommentWithoutRule = false reportIgnoreCommentWithoutRule = false
allowedUntypedLibraries = ["frontmatter", "pygments", "pymdownx", "l2m4m"] allowedUntypedLibraries = ["frontmatter", "pygments", "pymdownx", "l2m4m", "feedgen"]
[tool.ruff] [tool.ruff]
line-length = 70 line-length = 70
indent-width = 4 indent-width = 4
target-version = "py311" target-version = "py312"
[tool.ruff.lint] [tool.ruff.lint]
fixable = ["ALL"] fixable = ["ALL"]

View file

@ -30,6 +30,7 @@ class ZonaBuilder:
self.config.build.include_drafts = True self.config.build.include_drafts = True
self.items: list[Item] = [] self.items: list[Item] = []
self.item_map: dict[Path, Item] = {} self.item_map: dict[Path, Item] = {}
self.fresh: bool = True
def _discover(self): def _discover(self):
layout = self.layout layout = self.layout
@ -48,12 +49,14 @@ class ZonaBuilder:
destination=destination, destination=destination,
url=str(destination.relative_to(layout.output)), url=str(destination.relative_to(layout.output)),
) )
if path.name.endswith(".md") and not path.is_relative_to( if path.name.endswith(
".md"
) and not path.is_relative_to(
layout.root / "content" / "static" layout.root / "content" / "static"
): ):
logger.debug(f"Parsing {path.name}.") logger.debug(f"Parsing {path.name}.")
item.metadata, item.content = parse_metadata(path) item.metadata, item.content = parse_metadata(path)
if ( if item.metadata.ignore or (
item.metadata.draft item.metadata.draft
and not self.config.build.include_drafts and not self.config.build.include_drafts
): ):
@ -69,11 +72,13 @@ class ZonaBuilder:
item.copy = False item.copy = False
name = destination.stem name = destination.stem
if name == "index": if name == "index":
item.destination = item.destination.with_suffix( item.destination = (
".html" item.destination.with_suffix(".html")
) )
else: else:
relative = path.relative_to(base).with_suffix("") relative = path.relative_to(base).with_suffix(
""
)
name = relative.stem name = relative.stem
item.destination = ( item.destination = (
layout.output layout.output
@ -85,7 +90,9 @@ class ZonaBuilder:
layout.output layout.output
) )
item.url = ( item.url = (
"" if rel_url == Path(".") else rel_url.as_posix() ""
if rel_url == Path(".")
else rel_url.as_posix()
) )
items.append(item) items.append(item)
self.items = items self.items = items
@ -99,6 +106,13 @@ class ZonaBuilder:
else date.min, else date.min,
reverse=True, reverse=True,
) )
posts = len(post_list)
for i, item in enumerate(post_list):
prev = post_list[i - 1] if i > 0 else None
next = post_list[i + 1] if i < posts - 2 else None
item.previous = prev
item.next = next
templater = Templater( templater = Templater(
config=self.config, config=self.config,
template_dir=self.layout.templates, template_dir=self.layout.templates,
@ -111,7 +125,9 @@ class ZonaBuilder:
# write code highlighting stylesheet # write code highlighting stylesheet
if self.config.markdown.syntax_highlighting.enabled: if self.config.markdown.syntax_highlighting.enabled:
pygments_style = zmd.get_style_defs(self.config) pygments_style = zmd.get_style_defs(self.config)
pygments_path = self.layout.output / "static" / "pygments.css" pygments_path = (
self.layout.output / "static" / "pygments.css"
)
util.ensure_parents(pygments_path) util.ensure_parents(pygments_path)
pygments_path.write_text(pygments_style) pygments_path.write_text(pygments_style)
for item in self.item_map.values(): for item in self.item_map.values():
@ -145,8 +161,17 @@ class ZonaBuilder:
and self.layout.output.is_dir() and self.layout.output.is_dir()
): ):
logger.debug("Removing stale output...") logger.debug("Removing stale output...")
shutil.rmtree(self.layout.output) # only remove output dir's children
# to avoid breaking live preview
for child in self.layout.output.iterdir():
if child.is_file() or child.is_symlink():
child.unlink()
elif child.is_dir():
shutil.rmtree(child)
if not self.fresh:
self.layout = self.layout.refresh()
logger.debug("Discovering...") logger.debug("Discovering...")
self._discover() self._discover()
logger.debug("Building...") logger.debug("Building...")
self._build() self._build()
self.fresh = False

View file

@ -59,7 +59,9 @@ def build(
""" """
if draft: if draft:
print("Option override: including drafts.") print("Option override: including drafts.")
builder = ZonaBuilder(cli_root=root, cli_output=output, draft=draft) builder = ZonaBuilder(
cli_root=root, cli_output=output, draft=draft
)
builder.build() builder.build()
@ -73,7 +75,9 @@ def serve(
] = None, ] = None,
host: Annotated[ host: Annotated[
str, str,
typer.Option("--host", help="Hostname for live preview server."), typer.Option(
"--host", help="Hostname for live preview server."
),
] = "localhost", ] = "localhost",
port: Annotated[ port: Annotated[
int, int,
@ -95,14 +99,15 @@ def serve(
bool, bool,
typer.Option("--final", "-f", help="Don't include drafts."), typer.Option("--final", "-f", help="Don't include drafts."),
] = False, ] = False,
no_live_reload: Annotated[ live_reload: Annotated[
bool, bool | None,
typer.Option( typer.Option(
"--no-live-reload", "--live-reload/--no-live-reload",
"-n", "-l/-L",
help="Don't automatically reload web preview.", help="Automatically reload web preview. Overrides config.",
show_default=False,
), ),
] = False, ] = None,
): ):
""" """
Build the website and start a live preview server. Build the website and start a live preview server.
@ -115,13 +120,17 @@ def serve(
print("Preview without drafts.") print("Preview without drafts.")
else: else:
print("Preview with drafts.") print("Preview with drafts.")
if live_reload is None:
reload = None
else:
reload = live_reload
server.serve( server.serve(
root=root, root=root,
output=output, output=output,
draft=not final, draft=not final,
host=host, host=host,
port=port, port=port,
live_reload=not no_live_reload, user_reload=reload,
) )

View file

@ -58,19 +58,37 @@ class BuildConfig:
include_drafts: bool = False include_drafts: bool = False
@dataclass
class ReloadConfig:
enabled: bool = True
scroll_tolerance: int = 100
@dataclass
class ServerConfig:
reload: ReloadConfig = field(default_factory=ReloadConfig)
IGNORELIST = [".marksman.toml"] IGNORELIST = [".marksman.toml"]
@dataclass @dataclass
class ZonaConfig: class ZonaConfig:
base_url: str = "/" base_url: str = "/"
link: str = "example.com"
title: str = "Zona Website"
description: str = "My zona website."
language: str = "en"
# dictionary where key is name, value is url # dictionary where key is name, value is url
sitemap: SitemapConfig = field(default_factory=lambda: {"Home": "/"}) sitemap: SitemapConfig = field(
default_factory=lambda: {"Home": "/"}
)
# list of globs relative to content that should be ignored # list of globs relative to content that should be ignored
ignore: list[str] = field(default_factory=lambda: IGNORELIST) ignore: list[str] = field(default_factory=lambda: IGNORELIST)
markdown: MarkdownConfig = field(default_factory=MarkdownConfig) markdown: MarkdownConfig = field(default_factory=MarkdownConfig)
build: BuildConfig = field(default_factory=BuildConfig) build: BuildConfig = field(default_factory=BuildConfig)
blog: BlogConfig = field(default_factory=BlogConfig) blog: BlogConfig = field(default_factory=BlogConfig)
server: ServerConfig = field(default_factory=ServerConfig)
@classmethod @classmethod
def from_file(cls, path: Path) -> "ZonaConfig": def from_file(cls, path: Path) -> "ZonaConfig":

View file

@ -1,20 +1,53 @@
:root { :root {
--main-text-color: #b4b4b4; --main-text-color: #b4b4b4;
--main-bg-color: #121212; --main-text-opaque-color: rgba(180, 180, 180, 0.8);
--main-link-color: #df6464; --main-bg-color: #121212;
--main-heading-color: #df6464; --main-link-color: #df6464;
--main-bullet-color: #d87c4a; --main-heading-color: #df6464;
--main-transparent: rgba(255, 255, 255, 0.15); --main-bullet-color: #d87c4a;
--main-small-text-color: rgba(255, 255, 255, 0.45); --orange-rgb: rgba(216, 124, 74, 0.6);
--main-transparent: rgba(255, 255, 255, 0.15);
--main-small-text-color: rgba(255, 255, 255, 0.45);
}
html {
scroll-behavior: smooth;
} }
body { body {
line-height: 1.6; margin: 0;
font-size: 18px; line-height: 1.6;
font-family: sans-serif; font-size: 18px;
background: var(--main-bg-color); font-family: sans-serif;
color: var(--main-text-color); background: var(--main-bg-color);
padding-left: calc(100vw - 100%); color: var(--main-text-color);
padding-left: calc(100vw - 100%);
}
header {
padding-top: -1rem;
margin-top: -1rem;
font-family: monospace;
text-transform: lowercase;
}
.post-nav {
font-family: monospace;
}
.post-nav .symbol {
color: var(--main-bullet-color);
}
.post-nav a {
margin: 0 2px;
}
.site-logo {
color: inherit;
font-weight: bold;
text-decoration: none;
/* font-size: 1.75rem;*/
} }
.toclink { .toclink {
@ -63,278 +96,351 @@ h3,
h4, h4,
h5, h5,
h6 { h6 {
color: var(--main-heading-color); color: var(--main-heading-color);
} }
h1 { h1 {
margin-block-start: 0.67rem; margin-block-start: 0.67rem;
margin-block-end: 0.67rem; margin-block-end: 0.67rem;
font-size: 2rem; font-size: 2rem;
font-weight: bold; font-weight: bold;
}
.title {
text-transform: lowercase;
font-family: monospace;
} }
article h1:first-of-type { article h1:first-of-type {
margin-block-start: 1.67rem; margin-block-start: 1.67rem;
} }
h2 { h2 {
margin-block-start: 0.83rem; margin-block-start: 0.83rem;
margin-block-end: 0.83rem; margin-block-end: 0.83rem;
font-size: 1.5rem; font-size: 1.5rem;
font-weight: bold; font-weight: bold;
} }
h3 { h3 {
margin-block-start: 1rem; margin-block-start: 1rem;
margin-block-end: 1rem; margin-block-end: 1rem;
font-size: 1.17em; font-size: 1.17em;
font-weight: bold; font-weight: bold;
} }
h4 { h4 {
margin-block-start: 1.33rem; margin-block-start: 1.33rem;
margin-block-end: 1.33rem; margin-block-end: 1.33rem;
font-size: 1rem; font-size: 1rem;
font-weight: bold; font-weight: bold;
} }
article h1+h4:first-of-type { article h1 + h4:first-of-type {
margin-block-start: 0rem; margin-block-start: 0rem;
} }
h5 { h5 {
margin-block-start: 1.67rem; margin-block-start: 1.67rem;
margin-block-end: 1.67rem; margin-block-end: 1.67rem;
font-size: 0.83rem; font-size: 0.83rem;
font-weight: bold; font-weight: bold;
} }
h6 { h6 {
margin-block-start: 2.33rem; margin-block-start: 2.33rem;
margin-block-end: 2.33rem; margin-block-end: 2.33rem;
font-size: 0.67rem; font-size: 0.67rem;
font-weight: bold; font-weight: bold;
} }
ul { ul {
list-style-type: disc; list-style-type: disc;
/* or any other list style */ /* or any other list style */
} }
li::marker { li::marker {
color: var(--main-bullet-color); color: var(--main-bullet-color);
/* Change this to your desired color */ /* Change this to your desired color */
} }
a { a {
color: var(--main-link-color); color: var(--main-link-color);
text-decoration: none;
position: relative;
} }
a:hover { a::after {
background: var(--main-transparent); content: "";
position: absolute;
left: 0;
bottom: -2px;
width: 100%;
height: 1px;
background-color: currentColor;
transform: scaleX(0);
transform-origin: center;
transition: transform 0.1s ease;
}
a:hover::after {
transform: scaleX(1);
}
a:has(> code)::after {
display: none;
} }
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: hidden;
img { img {
display: block; display: block;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
width: auto; width: auto;
height: auto; height: auto;
} }
blockquote { blockquote {
color: var(--main-small-text-color); color: var(--main-text-opaque-color);
border-left: 3px solid var(--main-transparent); border-left: 3px solid var(--orange-rgb);
padding: 0 1rem; padding: 0 1rem;
margin-left: 0; margin-left: 0;
margin-right: 0; margin-right: 0;
} }
hr { hr {
border: none; border: none;
height: 1px; height: 1px;
background: var(--main-small-text-color); background: var(--main-small-text-color);
opacity: 0.5;
}
time {
color: var(--main-bullet-color);
font-family: monospace;
}
.post-list-date {
font-size: 0.95rem;
} }
code { code {
background: var(--main-transparent); background: var(--main-transparent);
border-radius: 0.1875rem; border-radius: 0.1875rem;
/* padding: .0625rem .1875rem; */ /* padding: .0625rem .1875rem; */
/* margin: 0 .1875rem; */ /* margin: 0 .1875rem; */
} }
code, code,
pre { pre {
white-space: pre; white-space: pre;
word-wrap: break-word; word-wrap: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
font-family: monospace; font-family:
font-size: 0.95em; ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
Liberation Mono,
monospace;
font-size: 0.95em;
} }
pre { pre {
background-color: #151515; background-color: #1d1d1d;
color: #d5d5d5; color: #d5d5d5;
padding: 1em; padding: 1em;
border-radius: 5px; border-radius: 5px;
line-height: 1.5; line-height: 1.5;
overflow-x: auto; overflow-x: auto;
} }
/* Inline code styling */ /* Inline code styling */
:not(pre) > code { :not(pre) > code {
padding: 0.2em 0.4em; padding: 0.2em 0.4em;
border-radius: 3px; font-size: 0.85em;
background-color: #1d1d1d; line-height: 1;
color: #d5d5d5; background-color: #1d1d1d;
font-size: 0.85em; border-radius: 6px;
vertical-align: middle;
font-family:
ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
Liberation Mono,
monospace;
} }
/* Block code styling (inherits from pre) */ /* Block code styling (inherits from pre) */
pre code { pre code {
padding: 0; padding: 0;
border-radius: 0; border-radius: 0;
background: none; background: none;
} }
small { small {
font-size: 0.95rem; font-size: 0.95rem;
color: var(--main-small-text-color); color: var(--main-small-text-color);
} }
small a { small a {
color: inherit; color: inherit;
/* Inherit the color of the surrounding <small> text */ /* Inherit the color of the surrounding <small> text */
text-decoration: underline; text-decoration: underline;
/* Optional: Keep the underline to indicate a link */ /* Optional: Keep the underline to indicate a link */
} }
.title-container { .title-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
text-align: center; text-align: center;
} }
.title-container h1 { .title-container h1 {
margin: 0; margin: 0;
} }
.image-container { .image-container {
text-align: center; text-align: center;
margin: 20px 0; margin: 20px 0;
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: hidden;
/* Optional: add some spacing around the image container */ /* Optional: add some spacing around the image container */
} }
.image-container img { .image-container img {
max-width: 100%; max-width: 100%;
width: auto; width: auto;
max-height: 100%; max-height: 100%;
height: auto; height: auto;
} }
.fixed .image-container img { .fixed .image-container img {
max-width: 308px; max-width: 308px;
max-height: 308px; max-height: 308px;
} }
.image-container small { .image-container small {
display: block; display: block;
/* Ensure the caption is on a new line */ /* Ensure the caption is on a new line */
margin-top: 5px; margin-top: 5px;
/* Optional: adjust spacing between image and caption */ /* Optional: adjust spacing between image and caption */
} }
.image-container small a { .image-container small a {
color: inherit; color: inherit;
/* Ensure the link color matches the small text */ /* Ensure the link color matches the small text */
text-decoration: underline; text-decoration: underline;
/* Optional: underline to indicate a link */ /* Optional: underline to indicate a link */
} }
#header ul { #header ul {
list-style-type: none; list-style-type: none;
padding-left: 0; padding-left: 0;
} }
#header li { #header li {
display: inline; display: inline;
font-size: 1.2rem; font-size: 1.2rem;
margin-right: 1.2rem; margin-right: 1.2rem;
} }
#container { #container {
margin: 2.5rem auto; margin: 2.5rem auto;
width: 90%; width: 90%;
max-width: 60ch; max-width: 60ch;
} }
#postlistdiv ul { #postlistdiv ul {
list-style-type: none; list-style-type: none;
padding-left: 0; padding-left: 0;
} }
.moreposts { .moreposts {
font-size: 0.95rem; font-size: 0.95rem;
padding-left: 0.5rem; padding-left: 0.5rem;
} }
#nextprev { #nextprev {
text-align: center; text-align: center;
margin-top: 1.4rem; margin-top: 1.4rem;
font-size: 0.95rem; font-size: 0.95rem;
} }
#footer { #footer {
color: var(--main-small-text-color); color: var(--main-small-text-color);
} }
table { table {
border-collapse: collapse; border-collapse: collapse;
margin: 1.5rem auto; margin: 1.5rem auto;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
font-size: 0.85rem; font-size: 0.85rem;
text-align: left; /* Use center if you prefer */ text-align: left; /* Use center if you prefer */
} }
th, td { th, td {
border: 1px solid var(--main-transparent); border: 1px solid var(--main-transparent);
/*border: 1px solid var(--main-bullet-color);*/ /*border: 1px solid var(--main-bullet-color);*/
padding: 0.4rem 0.8rem; padding: 0.4rem 0.8rem;
vertical-align: middle; vertical-align: middle;
} }
thead th { thead th {
font-weight: bold; font-weight: bold;
background-color: rgba(255, 255, 255, 0.05); background-color: rgba(255, 255, 255, 0.05);
color: var(--main-text-color); color: var(--main-text-color);
} }
tbody tr:nth-child(even) { tbody tr:nth-child(even) {
background-color: rgba(255, 255, 255, 0.02); background-color: rgba(255, 255, 255, 0.02);
} }
tbody tr:hover { tbody tr:hover {
background-color: rgba(255, 255, 255, 0.05); background-color: rgba(255, 255, 255, 0.05);
} }
table code { table code {
font-family: monospace; font-family:
font-size: 0.85em; ui-monospace,
background: #1d1d1d; SFMono-Regular,
padding: 0.1em 0.25em; SF Mono,
border-radius: 3px; Menlo,
Consolas,
Liberation Mono,
monospace;
font-size: 0.85em;
background: #1d1d1d;
padding: 0.1em 0.25em;
border-radius: 3px;
} }
caption { caption {
margin-top: 0.5rem; margin-top: 0.5rem;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--main-small-text-color); color: var(--main-small-text-color);
} }
a > code {
text-decoration: none;
color: var(--main-link-color);
position: relative;
}
a:has(> code) {
text-decoration: none;
background: none;
/* position: static;*/
}
a:hover > code {
text-decoration: underline;
}
a:hover:has(> code) {
background: none;
}

View file

@ -0,0 +1,25 @@
(() => {
// if user at the bottom before reload, scroll to new bottom
if (localStorage.getItem("wasAtBottom") === "1") {
localStorage.removeItem("wasAtBottom");
window.addEventListener("load", () => {
requestAnimationFrame(() => {
window.scrollTo(0, document.body.scrollHeight);
});
});
}
const ws = new WebSocket("__SOCKET_ADDRESS__");
const tol = __SCROLL_TOLERANCE__;
ws.onmessage = event => {
if (event.data === "reload") {
// store flag if user currently at bottom
const nearBottom = window.innerHeight + window.scrollY
>= document.body.scrollHeight - tol;
if (nearBottom) {
localStorage.setItem("wasAtBottom", "1");
}
location.reload();
}
};
})();

View file

@ -2,7 +2,7 @@
{% block content %} {% block content %}
{% if metadata.show_title %} {% if metadata.show_title %}
<center><h1>{{ metadata.title }}</h1></center> {% include "title.html" %}
{% endif %} {% endif %}
{{ content | safe }} {{ content | safe }}
{% endblock %} {% endblock %}

View file

@ -1,13 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %} {% block content %} {% if metadata.show_title %} {%
include "title.html" %} {% if metadata.date %}
<center>
<time class="post-date" datetime="{{ metadata.date | safe }}"
>{{ metadata.date | safe}}</time>
</center>
{% endif %} {% endif %} {% if is_post %} {% include "post_nav.html" %} {% endif
%}
<hr>
{% block content %}
{% if metadata.show_title %}
<center><h1>{{ metadata.title }}</h1></center>
{% endif %}
{% if metadata.date %}
<center><small><time datetime="{{ metadata.date | safe }}">{{ metadata.date | safe}}</time></small></center>
{% endif %}
<article>{{ content | safe }}</article> <article>{{ content | safe }}</article>
{% endblock %} {% endblock %}

View file

@ -1,17 +1,20 @@
{% extends "base.html" %} {% extends "base.html" %} {% block content %}
{% block content %} {% if metadata.show_title %}
{% include "title.html" %}
<center><h1>{{ metadata.title }}</h1></center> {% endif %}
<article>{{ content | safe }}</article> <article>{{ content | safe }}</article>
{% if post_list %} {% if post_list %}
<ul> <ul>
{% for item in post_list %} {% for item in post_list %}
<li><small><time datetime="{{ item.metadata.date | safe }}">{{ item.metadata.date | safe}}</time></small>: <a href="/{{ item.url }}">{{ item.metadata.title }}</a></li> <li>
{% endfor %} <time class="post-list-date" datetime="{{ item.metadata.date | safe }}"
</ul> >{{ item.metadata.date | safe}}</time>: <a href="/{{ item.url }}"
{% endif %} >{{ item.metadata.title }}</a>
{% endblock %} </li>
{% endfor %}
</ul>
{% endif %} {% endblock %}

View file

@ -0,0 +1,9 @@
<div class="post-nav">
<center>
<span class="symbol">&lt;</span>{% if previous %}<a
href="{{ previous.url }}"
>prev</a>{% endif %}{% if previous and next %}<span class="symbol"
>|</span>{% endif %}{% if next %}<a href="{{ next.url }}">next</a>{% endif
%}<span class="symbol">&gt;</span>
</center>
</div>

View file

@ -0,0 +1,2 @@
<center><h1 class="title">{{ metadata.title }}</h1></center>

1
src/zona/feed.py Normal file
View file

@ -0,0 +1 @@

View file

@ -16,6 +16,22 @@ class Layout:
content: Path content: Path
templates: Path templates: Path
output: Path output: Path
shared_templates: util.TempDir | None
_validate: bool
def refresh(self):
logger.debug("Refreshing layout...")
if (
self.shared_templates
and not self.shared_templates.removed
):
logger.debug("Removing stale templates tempdir...")
self.shared_templates.remove()
return self.__class__.from_input(
root=self.root,
output=self.output,
validate=self._validate,
)
@classmethod @classmethod
def from_input( def from_input(
@ -31,6 +47,8 @@ class Layout:
output=(root / "public").resolve() output=(root / "public").resolve()
if not output if not output
else output, else output,
shared_templates=None,
_validate=validate,
) )
if validate: if validate:
logger.debug("Validating site layout...") logger.debug("Validating site layout...")
@ -39,10 +57,29 @@ class Layout:
raise FileNotFoundError( raise FileNotFoundError(
"Missing required content directory!" "Missing required content directory!"
) )
if not layout.templates.is_dir(): internal_templates = util.get_resource_dir("templates")
user_templates = layout.templates
if not user_templates.is_dir() or util.is_empty(
user_templates
):
logger.debug("Using default template directory.") logger.debug("Using default template directory.")
# use the included defaults # use the included defaults
layout.templates = util.get_resource_dir("templates") layout.templates = internal_templates
else:
seen: set[str] = set()
temp = util.TempDir()
logger.debug(
f"Creating shared template directory at {temp}"
)
for f in user_templates.iterdir():
if f.is_file():
util.copy_static_file(f, temp.path)
seen.add(f.name)
for f in internal_templates.iterdir():
if f.is_file() and f.name not in seen:
util.copy_static_file(f, temp.path)
layout.shared_templates = temp
layout.templates = temp.path
return layout return layout

View file

@ -18,12 +18,15 @@ class Metadata:
date: date date: date
description: str | None description: str | None
show_title: bool = True show_title: bool = True
show_date: bool = True
show_nav: bool = True
style: str | None = "/static/style.css" style: str | None = "/static/style.css"
header: bool = True header: bool = True
footer: bool = True footer: bool = True
template: str | None = None template: str | None = None
post: bool | None = None post: bool | None = None
draft: bool = False draft: bool = False
ignore: bool = False
math: bool = True math: bool = True

View file

@ -1,3 +1,5 @@
from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
@ -21,6 +23,8 @@ class Item:
type: ItemType | None = None type: ItemType | None = None
copy: bool = True copy: bool = True
post: bool = False post: bool = False
next: Item | None = None
previous: Item | None = None
# @dataclass # @dataclass

View file

@ -13,6 +13,7 @@ from rich import print
from watchdog.events import FileSystemEvent, FileSystemEventHandler from watchdog.events import FileSystemEvent, FileSystemEventHandler
from watchdog.observers import Observer from watchdog.observers import Observer
from zona import util
from zona.builder import ZonaBuilder from zona.builder import ZonaBuilder
from zona.log import get_logger from zona.log import get_logger
from zona.websockets import WebSocketServer from zona.websockets import WebSocketServer
@ -20,18 +21,23 @@ from zona.websockets import WebSocketServer
logger = get_logger() logger = get_logger()
def make_reload_script(host: str, port: int) -> str: def make_reload_script(
host: str, port: int, scroll_tolerance: int
) -> str:
"""Generates the JavaScript that must be injected into HTML pages for the live reloading to work.""" """Generates the JavaScript that must be injected into HTML pages for the live reloading to work."""
return f""" js = util.get_resource("server/inject.js").contents
<script> js = util.minify_js(js)
const ws = new WebSocket("ws://{host}:{port}"); address = f"ws://{host}:{port}"
ws.onmessage = event => {{ for placeholder, value in (
if (event.data === "reload") {{ ("__SOCKET_ADDRESS__", address),
location.reload(); ("__SCROLL_TOLERANCE__", scroll_tolerance),
}} ):
}}; if placeholder not in js:
</script> raise ValueError(
""" f"{placeholder} missing from reload script template!"
)
js = js.replace(placeholder, str(value))
return f"<script>{js}</script>"
def make_handler_class(script: str): def make_handler_class(script: str):
@ -171,12 +177,13 @@ def serve(
draft: bool = True, draft: bool = True,
host: str = "localhost", host: str = "localhost",
port: int = 8000, port: int = 8000,
live_reload: bool = True, user_reload: bool | None = None,
): ):
"""Serve preview website with live reload and automatic rebuild.""" """Serve preview website with live reload and automatic rebuild."""
# create temp dir, automatic cleanup # create temp dir, automatic cleanup
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
builder = ZonaBuilder(root, Path(tmp), draft) builder = ZonaBuilder(root, Path(tmp), draft)
config = builder.config
# initial site build # initial site build
builder.build() builder.build()
# use discovered paths if none provided # use discovered paths if none provided
@ -185,13 +192,21 @@ def serve(
if root is None: if root is None:
root = builder.layout.root root = builder.layout.root
# spin up websocket server for live reloading # use config value unless overridden by user
if live_reload: reload = config.server.reload.enabled
if user_reload is not None:
reload = user_reload
if reload:
print("Live reloading is enabled.")
# spin up websocket server for live reloading
ws_port = port + 1 ws_port = port + 1
ws_server = WebSocketServer(host, ws_port) ws_server = WebSocketServer(host, ws_port)
ws_server.start() ws_server.start()
# generate reload script for injection # generate reload script for injection
reload_script = make_reload_script(host, ws_port) scroll_tolerance = config.server.reload.scroll_tolerance
reload_script = make_reload_script(
host, ws_port, scroll_tolerance
)
# generate handler with reload script as attribute # generate handler with reload script as attribute
handler = make_handler_class(reload_script) handler = make_handler_class(reload_script)
else: else:
@ -222,9 +237,13 @@ def serve(
observer.schedule( observer.schedule(
event_handler, path=str(root / "content"), recursive=True event_handler, path=str(root / "content"), recursive=True
) )
observer.schedule( templates = root / "templates"
event_handler, path=str(root / "templates"), recursive=True if templates.is_dir():
) observer.schedule(
event_handler,
path=str(templates),
recursive=True,
)
observer.start() observer.start()
# function to shut down gracefully # function to shut down gracefully

View file

@ -3,6 +3,7 @@ from typing import Literal
from jinja2 import Environment, FileSystemLoader, select_autoescape from jinja2 import Environment, FileSystemLoader, select_autoescape
from zona import util
from zona.config import ZonaConfig from zona.config import ZonaConfig
from zona.markdown import md_to_html from zona.markdown import md_to_html
from zona.models import Item from zona.models import Item
@ -35,6 +36,7 @@ class Templater:
template_dir: Path, template_dir: Path,
post_list: list[Item], post_list: list[Item],
): ):
# build temporary template dir
self.env: Environment = Environment( self.env: Environment = Environment(
loader=FileSystemLoader(template_dir), loader=FileSystemLoader(template_dir),
autoescape=select_autoescape(["html", "xml"]), autoescape=select_autoescape(["html", "xml"]),
@ -76,5 +78,12 @@ class Templater:
metadata=meta, metadata=meta,
header=header, header=header,
footer=footer, footer=footer,
is_post=item.post,
next=util.normalize_url(item.next.url)
if item.next
else None,
previous=util.normalize_url(item.previous.url)
if item.previous
else None,
post_list=self.post_list, post_list=self.post_list,
) )

View file

@ -1,10 +1,36 @@
import fnmatch import fnmatch
import re
import shutil
import string import string
import tempfile
import weakref
from importlib import resources from importlib import resources
from importlib.resources.abc import Traversable from importlib.resources.abc import Traversable
from pathlib import Path from pathlib import Path
from shutil import copy2 from shutil import copy2
from typing import NamedTuple from typing import Any, NamedTuple, override
class TempDir:
"""Temporary directory that cleans up when it's garbage collected."""
def __init__(self):
self._tempdir: str = tempfile.mkdtemp()
self.path: Path = Path(self._tempdir)
self._finalizer: weakref.finalize[Any, Any] = (
weakref.finalize(self, shutil.rmtree, self._tempdir)
)
def remove(self):
self._finalizer()
@property
def removed(self):
return not self._finalizer.alive
@override
def __repr__(self) -> str:
return f"<TempDir {self.path}>"
class ZonaResource(NamedTuple): class ZonaResource(NamedTuple):
@ -12,6 +38,17 @@ class ZonaResource(NamedTuple):
contents: str contents: str
def get_resource(path: str) -> ZonaResource:
"""Load the packaged resource in data/path"""
file = resources.files("zona").joinpath("data", path)
if file.is_file():
return ZonaResource(name=path, contents=file.read_text())
else:
raise FileNotFoundError(
f"{path} is not a valid Zona resource!"
)
def get_resources(subdir: str) -> list[ZonaResource]: def get_resources(subdir: str) -> list[ZonaResource]:
"""Load the packaged resources in data/subdir""" """Load the packaged resources in data/subdir"""
out: list[ZonaResource] = [] out: list[ZonaResource] = []
@ -53,6 +90,15 @@ def copy_static_file(src: Path, dst: Path):
copy2(src, dst) copy2(src, dst)
def is_empty(path: Path) -> bool:
"""If given a file, check if it has any non-whitespace content.
If given a directory, check if it has any children."""
if path.is_file():
return path.read_text().strip() == ""
else:
return not any(path.iterdir())
def filename_to_title(path: Path) -> str: def filename_to_title(path: Path) -> str:
name = path.stem name = path.stem
words = name.replace("-", " ").replace("_", " ") words = name.replace("-", " ").replace("_", " ")
@ -73,3 +119,23 @@ def should_ignore(
fnmatch.fnmatch(str(rel_path), pattern) fnmatch.fnmatch(str(rel_path), pattern)
for pattern in patterns for pattern in patterns
) )
MINIFY_JS_PATTERN = re.compile(
r"""
//.*?$ |
/\*.*?\*/ |
\s+
""",
re.MULTILINE | re.DOTALL | re.VERBOSE,
)
def minify_js(js: str) -> str:
"""Naively minifies JavaScript by stripping comments and whitespace."""
return MINIFY_JS_PATTERN.sub(
# replace whitespace with single space,
# strip comments
lambda m: " " if m.group(0).isspace() else "",
js,
).strip()

54
uv.lock generated
View file

@ -44,6 +44,16 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/35/386550fd60316d1e37eccdda609b074113298f23cef5bddb2049823fe666/dacite-1.9.2-py3-none-any.whl", hash = "sha256:053f7c3f5128ca2e9aceb66892b1a3c8936d02c686e707bee96e19deef4bc4a0", size = 16600, upload-time = "2025-02-05T09:27:24.345Z" }, { url = "https://files.pythonhosted.org/packages/94/35/386550fd60316d1e37eccdda609b074113298f23cef5bddb2049823fe666/dacite-1.9.2-py3-none-any.whl", hash = "sha256:053f7c3f5128ca2e9aceb66892b1a3c8936d02c686e707bee96e19deef4bc4a0", size = 16600, upload-time = "2025-02-05T09:27:24.345Z" },
] ]
[[package]]
name = "feedgen"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "lxml" },
{ name = "python-dateutil" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6b/59/be0a6f852b5dfbf19e6c8e962c8f41407697f9f52a7902250ed98683ae89/feedgen-1.0.0.tar.gz", hash = "sha256:d9bd51c3b5e956a2a52998c3708c4d2c729f2fcc311188e1e5d3b9726393546a", size = 258496, upload-time = "2023-12-25T18:04:08.421Z" }
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.1.0" version = "2.1.0"
@ -87,6 +97,46 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/fd/aba08bb9e527168efad57985d7db9a853eb2384b1efa5ca5f3a3794c9cef/latex2mathml-3.78.0-py3-none-any.whl", hash = "sha256:1aeca3dc027b3006ad7b301b7f4a15ffbb4c1451e3dc8c3389e97b37b497e1d6", size = 73673, upload-time = "2025-05-03T16:51:51.991Z" }, { url = "https://files.pythonhosted.org/packages/1e/fd/aba08bb9e527168efad57985d7db9a853eb2384b1efa5ca5f3a3794c9cef/latex2mathml-3.78.0-py3-none-any.whl", hash = "sha256:1aeca3dc027b3006ad7b301b7f4a15ffbb4c1451e3dc8c3389e97b37b497e1d6", size = 73673, upload-time = "2025-05-03T16:51:51.991Z" },
] ]
[[package]]
name = "lxml"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c5/ed/60eb6fa2923602fba988d9ca7c5cdbd7cf25faa795162ed538b527a35411/lxml-6.0.0.tar.gz", hash = "sha256:032e65120339d44cdc3efc326c9f660f5f7205f3a535c1fdbf898b29ea01fb72", size = 4096938, upload-time = "2025-06-26T16:28:19.373Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/89/c3/d01d735c298d7e0ddcedf6f028bf556577e5ab4f4da45175ecd909c79378/lxml-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78718d8454a6e928470d511bf8ac93f469283a45c354995f7d19e77292f26108", size = 8429515, upload-time = "2025-06-26T16:26:06.776Z" },
{ url = "https://files.pythonhosted.org/packages/06/37/0e3eae3043d366b73da55a86274a590bae76dc45aa004b7042e6f97803b1/lxml-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:84ef591495ffd3f9dcabffd6391db7bb70d7230b5c35ef5148354a134f56f2be", size = 4601387, upload-time = "2025-06-26T16:26:09.511Z" },
{ url = "https://files.pythonhosted.org/packages/a3/28/e1a9a881e6d6e29dda13d633885d13acb0058f65e95da67841c8dd02b4a8/lxml-6.0.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:2930aa001a3776c3e2601cb8e0a15d21b8270528d89cc308be4843ade546b9ab", size = 5228928, upload-time = "2025-06-26T16:26:12.337Z" },
{ url = "https://files.pythonhosted.org/packages/9a/55/2cb24ea48aa30c99f805921c1c7860c1f45c0e811e44ee4e6a155668de06/lxml-6.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:219e0431ea8006e15005767f0351e3f7f9143e793e58519dc97fe9e07fae5563", size = 4952289, upload-time = "2025-06-28T18:47:25.602Z" },
{ url = "https://files.pythonhosted.org/packages/31/c0/b25d9528df296b9a3306ba21ff982fc5b698c45ab78b94d18c2d6ae71fd9/lxml-6.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bd5913b4972681ffc9718bc2d4c53cde39ef81415e1671ff93e9aa30b46595e7", size = 5111310, upload-time = "2025-06-28T18:47:28.136Z" },
{ url = "https://files.pythonhosted.org/packages/e9/af/681a8b3e4f668bea6e6514cbcb297beb6de2b641e70f09d3d78655f4f44c/lxml-6.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:390240baeb9f415a82eefc2e13285016f9c8b5ad71ec80574ae8fa9605093cd7", size = 5025457, upload-time = "2025-06-26T16:26:15.068Z" },
{ url = "https://files.pythonhosted.org/packages/99/b6/3a7971aa05b7be7dfebc7ab57262ec527775c2c3c5b2f43675cac0458cad/lxml-6.0.0-cp312-cp312-manylinux_2_27_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d6e200909a119626744dd81bae409fc44134389e03fbf1d68ed2a55a2fb10991", size = 5657016, upload-time = "2025-07-03T19:19:06.008Z" },
{ url = "https://files.pythonhosted.org/packages/69/f8/693b1a10a891197143c0673fcce5b75fc69132afa81a36e4568c12c8faba/lxml-6.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca50bd612438258a91b5b3788c6621c1f05c8c478e7951899f492be42defc0da", size = 5257565, upload-time = "2025-06-26T16:26:17.906Z" },
{ url = "https://files.pythonhosted.org/packages/a8/96/e08ff98f2c6426c98c8964513c5dab8d6eb81dadcd0af6f0c538ada78d33/lxml-6.0.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:c24b8efd9c0f62bad0439283c2c795ef916c5a6b75f03c17799775c7ae3c0c9e", size = 4713390, upload-time = "2025-06-26T16:26:20.292Z" },
{ url = "https://files.pythonhosted.org/packages/a8/83/6184aba6cc94d7413959f6f8f54807dc318fdcd4985c347fe3ea6937f772/lxml-6.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afd27d8629ae94c5d863e32ab0e1d5590371d296b87dae0a751fb22bf3685741", size = 5066103, upload-time = "2025-06-26T16:26:22.765Z" },
{ url = "https://files.pythonhosted.org/packages/ee/01/8bf1f4035852d0ff2e36a4d9aacdbcc57e93a6cd35a54e05fa984cdf73ab/lxml-6.0.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:54c4855eabd9fc29707d30141be99e5cd1102e7d2258d2892314cf4c110726c3", size = 4791428, upload-time = "2025-06-26T16:26:26.461Z" },
{ url = "https://files.pythonhosted.org/packages/29/31/c0267d03b16954a85ed6b065116b621d37f559553d9339c7dcc4943a76f1/lxml-6.0.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c907516d49f77f6cd8ead1322198bdfd902003c3c330c77a1c5f3cc32a0e4d16", size = 5678523, upload-time = "2025-07-03T19:19:09.837Z" },
{ url = "https://files.pythonhosted.org/packages/5c/f7/5495829a864bc5f8b0798d2b52a807c89966523140f3d6fa3a58ab6720ea/lxml-6.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:36531f81c8214e293097cd2b7873f178997dae33d3667caaae8bdfb9666b76c0", size = 5281290, upload-time = "2025-06-26T16:26:29.406Z" },
{ url = "https://files.pythonhosted.org/packages/79/56/6b8edb79d9ed294ccc4e881f4db1023af56ba451909b9ce79f2a2cd7c532/lxml-6.0.0-cp312-cp312-win32.whl", hash = "sha256:690b20e3388a7ec98e899fd54c924e50ba6693874aa65ef9cb53de7f7de9d64a", size = 3613495, upload-time = "2025-06-26T16:26:31.588Z" },
{ url = "https://files.pythonhosted.org/packages/0b/1e/cc32034b40ad6af80b6fd9b66301fc0f180f300002e5c3eb5a6110a93317/lxml-6.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:310b719b695b3dd442cdfbbe64936b2f2e231bb91d998e99e6f0daf991a3eba3", size = 4014711, upload-time = "2025-06-26T16:26:33.723Z" },
{ url = "https://files.pythonhosted.org/packages/55/10/dc8e5290ae4c94bdc1a4c55865be7e1f31dfd857a88b21cbba68b5fea61b/lxml-6.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:8cb26f51c82d77483cdcd2b4a53cda55bbee29b3c2f3ddeb47182a2a9064e4eb", size = 3674431, upload-time = "2025-06-26T16:26:35.959Z" },
{ url = "https://files.pythonhosted.org/packages/79/21/6e7c060822a3c954ff085e5e1b94b4a25757c06529eac91e550f3f5cd8b8/lxml-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6da7cd4f405fd7db56e51e96bff0865b9853ae70df0e6720624049da76bde2da", size = 8414372, upload-time = "2025-06-26T16:26:39.079Z" },
{ url = "https://files.pythonhosted.org/packages/a4/f6/051b1607a459db670fc3a244fa4f06f101a8adf86cda263d1a56b3a4f9d5/lxml-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b34339898bb556a2351a1830f88f751679f343eabf9cf05841c95b165152c9e7", size = 4593940, upload-time = "2025-06-26T16:26:41.891Z" },
{ url = "https://files.pythonhosted.org/packages/8e/74/dd595d92a40bda3c687d70d4487b2c7eff93fd63b568acd64fedd2ba00fe/lxml-6.0.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:51a5e4c61a4541bd1cd3ba74766d0c9b6c12d6a1a4964ef60026832aac8e79b3", size = 5214329, upload-time = "2025-06-26T16:26:44.669Z" },
{ url = "https://files.pythonhosted.org/packages/52/46/3572761efc1bd45fcafb44a63b3b0feeb5b3f0066886821e94b0254f9253/lxml-6.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d18a25b19ca7307045581b18b3ec9ead2b1db5ccd8719c291f0cd0a5cec6cb81", size = 4947559, upload-time = "2025-06-28T18:47:31.091Z" },
{ url = "https://files.pythonhosted.org/packages/94/8a/5e40de920e67c4f2eef9151097deb9b52d86c95762d8ee238134aff2125d/lxml-6.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d4f0c66df4386b75d2ab1e20a489f30dc7fd9a06a896d64980541506086be1f1", size = 5102143, upload-time = "2025-06-28T18:47:33.612Z" },
{ url = "https://files.pythonhosted.org/packages/7c/4b/20555bdd75d57945bdabfbc45fdb1a36a1a0ff9eae4653e951b2b79c9209/lxml-6.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f4b481b6cc3a897adb4279216695150bbe7a44c03daba3c894f49d2037e0a24", size = 5021931, upload-time = "2025-06-26T16:26:47.503Z" },
{ url = "https://files.pythonhosted.org/packages/b6/6e/cf03b412f3763d4ca23b25e70c96a74cfece64cec3addf1c4ec639586b13/lxml-6.0.0-cp313-cp313-manylinux_2_27_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a78d6c9168f5bcb20971bf3329c2b83078611fbe1f807baadc64afc70523b3a", size = 5645469, upload-time = "2025-07-03T19:19:13.32Z" },
{ url = "https://files.pythonhosted.org/packages/d4/dd/39c8507c16db6031f8c1ddf70ed95dbb0a6d466a40002a3522c128aba472/lxml-6.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ae06fbab4f1bb7db4f7c8ca9897dc8db4447d1a2b9bee78474ad403437bcc29", size = 5247467, upload-time = "2025-06-26T16:26:49.998Z" },
{ url = "https://files.pythonhosted.org/packages/4d/56/732d49def0631ad633844cfb2664563c830173a98d5efd9b172e89a4800d/lxml-6.0.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:1fa377b827ca2023244a06554c6e7dc6828a10aaf74ca41965c5d8a4925aebb4", size = 4720601, upload-time = "2025-06-26T16:26:52.564Z" },
{ url = "https://files.pythonhosted.org/packages/8f/7f/6b956fab95fa73462bca25d1ea7fc8274ddf68fb8e60b78d56c03b65278e/lxml-6.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1676b56d48048a62ef77a250428d1f31f610763636e0784ba67a9740823988ca", size = 5060227, upload-time = "2025-06-26T16:26:55.054Z" },
{ url = "https://files.pythonhosted.org/packages/97/06/e851ac2924447e8b15a294855caf3d543424364a143c001014d22c8ca94c/lxml-6.0.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:0e32698462aacc5c1cf6bdfebc9c781821b7e74c79f13e5ffc8bfe27c42b1abf", size = 4790637, upload-time = "2025-06-26T16:26:57.384Z" },
{ url = "https://files.pythonhosted.org/packages/06/d4/fd216f3cd6625022c25b336c7570d11f4a43adbaf0a56106d3d496f727a7/lxml-6.0.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4d6036c3a296707357efb375cfc24bb64cd955b9ec731abf11ebb1e40063949f", size = 5662049, upload-time = "2025-07-03T19:19:16.409Z" },
{ url = "https://files.pythonhosted.org/packages/52/03/0e764ce00b95e008d76b99d432f1807f3574fb2945b496a17807a1645dbd/lxml-6.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7488a43033c958637b1a08cddc9188eb06d3ad36582cebc7d4815980b47e27ef", size = 5272430, upload-time = "2025-06-26T16:27:00.031Z" },
{ url = "https://files.pythonhosted.org/packages/5f/01/d48cc141bc47bc1644d20fe97bbd5e8afb30415ec94f146f2f76d0d9d098/lxml-6.0.0-cp313-cp313-win32.whl", hash = "sha256:5fcd7d3b1d8ecb91445bd71b9c88bdbeae528fefee4f379895becfc72298d181", size = 3612896, upload-time = "2025-06-26T16:27:04.251Z" },
{ url = "https://files.pythonhosted.org/packages/f4/87/6456b9541d186ee7d4cb53bf1b9a0d7f3b1068532676940fdd594ac90865/lxml-6.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:2f34687222b78fff795feeb799a7d44eca2477c3d9d3a46ce17d51a4f383e32e", size = 4013132, upload-time = "2025-06-26T16:27:06.415Z" },
{ url = "https://files.pythonhosted.org/packages/b7/42/85b3aa8f06ca0d24962f8100f001828e1f1f1a38c954c16e71154ed7d53a/lxml-6.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:21db1ec5525780fd07251636eb5f7acb84003e9382c72c18c542a87c416ade03", size = 3672642, upload-time = "2025-06-26T16:27:09.888Z" },
]
[[package]] [[package]]
name = "markdown" name = "markdown"
version = "3.8.2" version = "3.8.2"
@ -459,10 +509,11 @@ wheels = [
[[package]] [[package]]
name = "zona" name = "zona"
version = "0.1.0" version = "1.0.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "dacite" }, { name = "dacite" },
{ name = "feedgen" },
{ name = "jinja2" }, { name = "jinja2" },
{ name = "l2m4m" }, { name = "l2m4m" },
{ name = "markdown" }, { name = "markdown" },
@ -489,6 +540,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "dacite", specifier = ">=1.9.2" }, { name = "dacite", specifier = ">=1.9.2" },
{ name = "feedgen", specifier = ">=1.0.0" },
{ name = "jinja2", specifier = ">=3.1.6" }, { name = "jinja2", specifier = ">=3.1.6" },
{ name = "l2m4m", specifier = ">=1.0.4" }, { name = "l2m4m", specifier = ">=1.0.4" },
{ name = "markdown", specifier = ">=3.8.2" }, { name = "markdown", specifier = ">=3.8.2" },