Compare commits

..

1 commit

Author SHA1 Message Date
a4717eed4c WIP: template merging (paths) 2025-07-02 14:39:27 -04:00
34 changed files with 383 additions and 2535 deletions

View file

@ -1,20 +0,0 @@
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: based-alpine
steps:
- uses: actions/checkout@v4
- name: setup cache
id: uv-cache
uses: https://git.ficd.sh/ficd/uv-cache@v1
- name: build
run: |
uv sync
uv build
- name: publish
run: |
uv publish --token ${{ secrets.PYPI_TOKEN }}

View file

@ -1,18 +0,0 @@
# this workflow checks if the project can be built successfully.
# it also serves to test whether based-alpine and uv-cache are working properly.
# Unit tests will be added here eventually
on: [push]
jobs:
test-build:
runs-on: based-alpine
steps:
- name: checkout source
uses: actions/checkout@v4
- name: setup cache
id: uv-cache
uses: https://git.ficd.sh/ficd/uv-cache@v1
- name: sync and build
run: |
uv sync
uv build
uv run zona --version

25
.kakrc
View file

@ -1,25 +0,0 @@
# commands to edit important files in the root
declare-option str project_root %sh{ git rev-parse --show-toplevel }
define-command -params 1 root-edit %{
edit %exp{%opt{project_root}/%arg{1}}
}
define-command just %{
root-edit justfile
}
define-command pyproject %{
root-edit pyproject.toml
}
define-command readme %{
root-edit README.md
}
define-command kakrc %{
root-edit .kakrc
}
# change working directory to the package
hook global -once BufCreate .* %{
change-directory %exp{%opt{project_root}/src/zona}
}

View file

@ -1,33 +0,0 @@
# 1.3.0
- Added RSS feed generation.
- Added default post description to configuration.
- Added time-of-day support to post `date` frontmatter parsing.
- `zona init` now only writes `footer.md` to the templates directory.
# 1.2.1
- Added `--version` flag to CLI.
# 1.2.0
- Improved the appearance and semantics of post navigation buttons.
- Navigation now follows "newer/older" logic.
- Added hover symbols to page titles.
- Improved the styling of hover symbols and links.
# 1.1.0
- Major improvements to default stylesheet.
- Frontmatter option to ignore file.
- Improvements to title and date rendering in templates.
- Added smooth scrolling to default stylesheet.
- Fixed a crash when user templates directory was missing when starting the
server.
- Added "next/previous" navigation buttons to posts.
- User template directory is now merged with defaults instead of it being one or
the other.
# 1.0.0
Initial release!

33
LICENSE
View file

@ -1,24 +1,13 @@
Copyright (c) 2025 Daniel Fichtinger <daniel@ficd.sh> Copyright (c) 2025 Daniel Fichtinger <daniel@ficd.ca>
Redistribution and use in source and binary forms, with or without Permission to use, copy, modify, and distribute this software for any
modification, are permitted provided that the following conditions purpose with or without fee is hereby granted, provided that the above
are met: copyright notice and this permission notice appear in all copies.
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
THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS "AS IS" AND THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
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.

499
README.md
View file

@ -1,493 +1,12 @@
<h1>zona</h1> # Zona
[zona](https://git.ficd.sh/ficd/zona) is an _opinionated_ static site generator This repository contains a Python rewrite of
written in Python. From a structured directory of Markdown content, zona builds [zona](https://git.sr.ht/~ficd/zona). The project was increasing in complexity
a simple static website. It's designed to get out of your way and let you focus and in need of a refactor. I decided that I would rather implement the features
on writing. in Python.
**What do I mean by opinionated?** I built zona primarily for myself. I've tried Once the rewrite is complete, this repository will be renamed to `zona`, and
making it flexible by exposing as many variables as possible to the template `zona` will become `zona-go` and archived.
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.
For an example of a website built with zona, please see See the [tracker](https://todo.sr.ht/~ficd/zona) for updates on the status of
[ficd.sh](https://ficd.sh). For a list of known problems, see the rewrite.
[Known Problems](#known-problems).
<!--toc:start-->
- [Features](#features)
- [Installation](#installation)
- [Usage](#usage)
- [Getting Started](#getting-started)
- [Building](#building)
- [Live Preview](#live-preview)
- [Live Reload](#live-reload)
- [How It Works](#how-it-works)
- [Site Layout](#site-layout)
- [Templates](#templates)
- [Markdown Footer](#markdown-footer)
- [RSS Feed Generation](#rss-feed-generation)
- [Internal Link Resolution](#internal-link-resolution)
- [Syntax Highlighting](#syntax-highlighting)
- [Markdown Extensions](#markdown-extensions)
- [Image Labels](#image-labels)
- [Frontmatter](#frontmatter)
- [Post List](#post-list)
- [Configuration](#configuration)
- [Sitemap](#sitemap)
- [Ignore List](#ignore-list)
- [Drafts](#drafts)
- [Known Problems](#known-problems)
<!--toc:end-->
## Features
- Live preview server:
- Automatic rebuild of site on file changes.
- Live refresh in browser preview.
- `jinja2` template support with sensible defaults included.
- Basic page, blog post, post list.
- RSS feed generation.
- 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.
- LaTeX support.
## Installation
Zona can be installed as a Python package. Instructions for
[`uv`](https://docs.astral.sh/uv/) are provided.
```sh
# 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
_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.
### Building
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.
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
`--output/-o` flag. The `--draft/-d` flag includes draft posts in the output.
### Live Preview
To make the writing process as frictionless as possible, zona ships with a live
preview server. It spins up an HTTP server, meaning that internal links work
properly (this is not the case if you simply open the `.html` files in your
browser.)
Additionally, the server watches for changes to all source files, and rebuilds
the website when they're modified. _Note: the entire website is rebuilt — this
ensures that links are properly resolved._
Drafts are enabled by default in live preview. Use `--final/-f` to disable 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 the
server. If you change the configuration, the server must also be restarted. The
live preview uses the same function as `zona build` internally; this means that
the output is also written to disk --- a temporary directory by default, unless
overridden with `-o/--output`.
#### Live Reload
Optionally, live reloading of the browser is also provided. With this feature
(enabled by default), your browser will automatically refresh open pages
whenever the site is rebuilt. The live reloading requires JavaScript support
from the browser — this is why the feature is optional.
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
The basic idea is this: after a rebuild, the server needs to notify your browser
to refresh the open pages. We implement this using a small amount of JavaScript.
The server injects a tiny script into any HTML page it serves; which causes your
browser to open a WebSocket connection with the server. When the site is
rebuilt, the server notifies your browser via the WebSocket, which reloads the
page.
Unfortunately, there is no way to implement this feature without using
JavaScript. **JavaScript is _only_ used for the live preview feature. The script
is injected by the server, and never written to the HTML files in the output
directory.**
### 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 merged with the defaults 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.
### Templates
The `templates` directory may contain any `jinja2` template files. You may
modify the existing templates or create your own. Your templates are merged with
the packaged defaults. To apply a certain template to a page, set the `template`
option in its [frontmatter](#frontmatter). The following public variables are
made available to the template engine:
| Name | Description |
| ----------- | -------------------------------------------------------- |
| `content` | The content of this page. |
| `url` | The resolved URL of this page. |
| `metadata` | The frontmatter of this page (_merged with defaults_). |
| `header` | The sitemap header in HTML form. Can be `False`. |
| `footer` | The footer in HTML form. Can be `False`. |
| `is_post` | Whether this page is a post. |
| `newer` | URL of the newer post in the post list. |
| `older` | URL of the older post in the post list. |
| `post_list` | A sorted list of `Item` objects. Meant for internal use. |
#### Markdown Footer
The `templates` directory can contain a file called `footer.md`. If it exists,
it's parsed and rendered into HTML, then made available to other templates as
the `footer` variable. If `footer.md` is missing but `footer.html` exists, then
it's used instead. **Note: links are _not_ resolved in the footer.**
### RSS Feed Generation
Zona can also generates an RSS feed containing your blog posts. This feature is
disabled by default, and you can enable it in the
[configuration](#configuration).
The default location is a file called `rss.xml` in the content root. All RSS
related configuration is specified in `config.yml`. If you plan to use the feed,
make sure to replace the placeholder `link`, `title`, `description`, and
`author` configuration values before you set `enabled: true`.
### 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://codeberg.org/ficd/pygments-kakoune)
- A lexer providing for highlighting Kakoune code. Available under the `kak`
and `kakrc` aliases.
- [pygments-ashen](https://codeberg.org/ficd/ashen/tree/main/item/pygments/README.md)
- An implementation of the [Ashen](https://codeberg.org/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)
```
### Markdown Extensions
- [BetterEm](https://facelessuser.github.io/pymdown-extensions/extensions/betterem/)
- [SuperFences](https://facelessuser.github.io/pymdown-extensions/extensions/superfences/)
- `disable_indented_code_blocks=True`
- [Extra](https://python-markdown.github.io/extensions/extra/)
- Excluding Fenced Code Blocks.
- [Caret](https://facelessuser.github.io/pymdown-extensions/extensions/caret/)
- [Tilde](https://facelessuser.github.io/pymdown-extensions/extensions/tilde/)
- [Sane Lists](https://python-markdown.github.io/extensions/sane_lists/)
- [EscapeAll](https://facelessuser.github.io/pymdown-extensions/extensions/escapeall/)
- `hardbreak=True`
- [LaTeX2MathML4Markdown](https://gitlab.com/parcifal/l2m4m/-/tree/develop?ref_type=heads)
- Disable per-file with the `math: false` frontmatter option.
### 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
<div class="image-container"><img src="static/markdown.png" title=
""> <small>This <strong>image</strong> has
<em>markup</em>.</small></div>
```
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. |
| `description` | `str \| none` = `none` | Description. If omitted, default from [config](#configuration) will be used. |
| `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. |
| `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. |
**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
feed:
enabled: true
timezone: UTC
path: rss.xml
link: https://example.com
title: Zona Website
description: My zona website.
language: en
author:
name: John Doe
email: john@doe.net
sitemap:
Home: /
ignore:
- .marksman.toml
markdown:
image_labels: true
tab_length: 2
syntax_highlighting:
enabled: true
theme: ashen
wrap: false
links:
external_new_tab: true
build:
clean_output_dir: true
include_drafts: false
blog:
dir: blog
defaults:
description: A blog post
server:
reload:
enabled: true
scroll_tolerance: 100
```
| Name | Description |
| -------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `feed.enabled` | Whether RSS feed should be generated. **Off by default**. |
| `feed.timezime` | Timezone to use for post `pubDate` values. Must be an IANA compliant string. |
| `feed.path` | Location of the feed, relative to content root. |
| `feed.link` | The base URL of the website. |
| `feed.title` | Website title. |
| `feed.description` | Website description. |
| `feed.language` | String specifying website's language code. |
| `author.name` | Your full name. |
| `author.email` | Your email address. |
| `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. |
| `markdown.links.external_new_tab` | Whether external links should be opened in a new tab. |
| `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. |
| `blog.defaults.description` | Default description for blog posts with no `description` in their frontmatter. |
| `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
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.ficd.sh/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://codeberg.org/ficd/ashen
[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,28 +2,6 @@
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,35 +1,25 @@
[project] [project]
name = "zona" name = "zona"
version = "1.2.2" version = "0.1.0"
description = "Opinionated static site generator." description = "Static site generator"
license = "BSD-3-Clause "
license-files = ["LICENSE"]
readme = "README.md" readme = "README.md"
authors = [ authors = [
{ name = "Daniel Fichtinger", email = "daniel@ficd.sh" }, { name = "Daniel Fichtinger", email = "daniel@ficd.ca" }
] ]
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", "marko>=2.1.4",
"l2m4m>=1.0.4", "pygments>=2.19.1",
"markdown>=3.8.2", "pygments-ashen>=0.1.0",
"pygments>=2.19.1", "pygments-kakoune>=0.1.0",
"pygments-ashen>=0.1.3", "python-frontmatter>=1.1.0",
"pygments-kakoune>=0.1.0", "rich>=14.0.0",
"pymdown-extensions>=10.16", "typer>=0.16.0",
"python-dateutil>=2.9.0.post0", "watchdog>=6.0.0",
"python-frontmatter>=1.1.0",
"rich>=14.0.0",
"typer>=0.16.0",
"watchdog>=6.0.0",
"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"
@ -48,7 +38,9 @@ exclude = [
] ]
executionEnvironments = [ executionEnvironments = [
{ root = "src" }, { root = "src" },
{ root = "tests", extraPaths = ["src"], reportPrivateUsage = false }, { root = "tests", extraPaths = [
"src",
], reportPrivateUsage = false },
] ]
# off | basic | standard | strict | recommended | all # off | basic | standard | strict | recommended | all
typeCheckingMode = "recommended" typeCheckingMode = "recommended"
@ -58,12 +50,12 @@ reportUnusedCallResult = false
reportCallInDefaultInitializer = false reportCallInDefaultInitializer = false
enableTypeIgnoreComments = true enableTypeIgnoreComments = true
reportIgnoreCommentWithoutRule = false reportIgnoreCommentWithoutRule = false
allowedUntypedLibraries = ["frontmatter", "pygments", "pymdownx", "l2m4m", "feedgen", "feedgen.feed"] allowedUntypedLibraries = ["frontmatter", "pygments"]
[tool.ruff] [tool.ruff]
line-length = 70 line-length = 70
indent-width = 4 indent-width = 4
target-version = "py312" target-version = "py311"
[tool.ruff.lint] [tool.ruff.lint]
fixable = ["ALL"] fixable = ["ALL"]
@ -76,6 +68,7 @@ quote-style = "double"
indent-style = "space" indent-style = "space"
docstring-code-format = true docstring-code-format = true
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = "src" pythonpath = "src"
testpaths = ["tests"] testpaths = ["tests"]
@ -87,8 +80,8 @@ filterwarnings = [
[dependency-groups] [dependency-groups]
dev = [ dev = [
"basedpyright>=1.29.4", "basedpyright>=1.29.4",
"pytest>=8.4.0", "pytest>=8.4.0",
"ruff>=0.11.13", "ruff>=0.11.13",
"types-pygments>=2.19.0.20250516", "types-pygments>=2.19.0.20250516",
] ]

View file

@ -1,17 +1,14 @@
import shutil
from datetime import date from datetime import date
from pathlib import Path
from feedgen.feed import FeedGenerator
from zona import markdown as zmd
from zona import util
from zona.config import ZonaConfig
from zona.layout import Layout, discover_layout
from zona.log import get_logger
from zona.metadata import parse_metadata
from zona.models import Item, ItemType from zona.models import Item, ItemType
from zona.metadata import parse_metadata
from zona import markdown as zmd
from zona.templates import Templater from zona.templates import Templater
from zona.layout import Layout, discover_layout
from zona.config import ZonaConfig
from zona import util
from pathlib import Path
from rich import print
from zona.log import get_logger
logger = get_logger() logger = get_logger()
@ -21,25 +18,19 @@ class ZonaBuilder:
self, self,
cli_root: Path | None = None, cli_root: Path | None = None,
cli_output: Path | None = None, cli_output: Path | None = None,
draft: bool = False,
): ):
logger.debug("Initializing ZonaBuilder.")
self.layout: Layout = discover_layout(cli_root, cli_output) self.layout: Layout = discover_layout(cli_root, cli_output)
self.config: ZonaConfig = ZonaConfig.from_file( self.config: ZonaConfig = ZonaConfig.from_file(
self.layout.root / "config.yml" self.layout.root / "config.yml"
) )
if draft:
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
items: list[Item] = [] items: list[Item] = []
base = layout.root / layout.content base = layout.root / layout.content
logger.debug(f"Discovering content in {base}.")
for path in base.rglob("*"): for path in base.rglob("*"):
if path.is_file() and not util.should_ignore( if path.is_file() and not util.should_ignore(
path, patterns=self.config.ignore, base=base path, patterns=self.config.ignore, base=base
@ -51,21 +42,11 @@ class ZonaBuilder:
destination=destination, destination=destination,
url=str(destination.relative_to(layout.output)), url=str(destination.relative_to(layout.output)),
) )
if path.name.endswith( if path.name.endswith(".md") and not path.is_relative_to(
".md"
) and not path.is_relative_to(
layout.root / "content" / "static" layout.root / "content" / "static"
): ):
logger.debug(f"Parsing {path.name}.") item.metadata, item.content = parse_metadata(path)
item.metadata, item.content = parse_metadata( if item.metadata.post == True:
path, config=self.config
)
if item.metadata.ignore or (
item.metadata.draft
and not self.config.build.include_drafts
):
continue
if item.metadata.post:
item.post = True item.post = True
elif item.metadata.post is None: elif item.metadata.post is None:
# check if in posts dir? # check if in posts dir?
@ -76,13 +57,11 @@ class ZonaBuilder:
item.copy = False item.copy = False
name = destination.stem name = destination.stem
if name == "index": if name == "index":
item.destination = ( item.destination = item.destination.with_suffix(
item.destination.with_suffix(".html") ".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
@ -94,55 +73,14 @@ 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)
# print(item)
self.items = items self.items = items
def generate_feed(self) -> bytes: def _build(self):
post_list = self._get_post_list()
config = self.config.feed
if config.link.endswith("/"):
config.link = config.link[:-2]
fg = FeedGenerator()
fg.id(config.link)
fg.title(config.title)
author = {
"name": config.author.name,
"email": config.author.email,
}
fg.author(author)
fg.link(
href=f"{config.link}/{config.path}",
rel="self",
type="application/rss+xml",
)
fg.language(config.language)
fg.description(config.description)
for post in post_list:
assert post.metadata
fe = fg.add_entry() # pyright: ignore[reportUnknownVariableType]
fe.id(f"{config.link}{util.normalize_url(post.url)}") # pyright: ignore[reportUnknownMemberType]
fe.link( # pyright: ignore[reportUnknownMemberType]
href=f"{config.link}{util.normalize_url(post.url)}"
)
fe.title(post.metadata.title) # pyright: ignore[reportUnknownMemberType]
fe.author(author) # pyright: ignore[reportUnknownMemberType]
desc = post.metadata.description
fe.description(desc) # pyright: ignore[reportUnknownMemberType]
date = post.metadata.date
fe.pubDate(date) # pyright: ignore[reportUnknownMemberType]
out: bytes = fg.rss_str(pretty=True) # pyright: ignore[reportUnknownVariableType]
assert isinstance(out, bytes)
return out
def _get_post_list(self) -> list[Item]:
assert self.items assert self.items
# sort according to date
# descending order
post_list: list[Item] = sorted( post_list: list[Item] = sorted(
[item for item in self.items if item.post], [item for item in self.items if item.post],
key=lambda item: item.metadata.date key=lambda item: item.metadata.date
@ -150,39 +88,15 @@ class ZonaBuilder:
else date.min, else date.min,
reverse=True, reverse=True,
) )
return post_list
def _build(self):
post_list = self._get_post_list()
# number of posts
# generate RSS here
posts = len(post_list)
# link post chronology
for i, item in enumerate(post_list):
# prev: older post
older = post_list[i + 1] if i + 1 < posts else None
# next: newer post
newer = post_list[i - 1] if i > 0 else None
item.older = older
item.newer = newer
templater = Templater( templater = Templater(
config=self.config, root=self.layout.root,
template_dir=self.layout.templates, template_dir=self.layout.templates,
post_list=post_list, post_list=post_list,
) )
self.item_map = { self.item_map = {
item.source.resolve(): item for item in self.items item.source.resolve(): item for item in self.items
} }
# print(item_map)
# write code highlighting stylesheet
if self.config.markdown.syntax_highlighting.enabled:
pygments_style = zmd.get_style_defs(self.config)
pygments_path = (
self.layout.output / "static" / "pygments.css"
)
util.ensure_parents(pygments_path)
pygments_path.write_text(pygments_style)
for item in self.item_map.values(): for item in self.item_map.values():
dst = item.destination dst = item.destination
# print(item) # print(item)
@ -197,7 +111,6 @@ class ZonaBuilder:
source=item.source, source=item.source,
layout=self.layout, layout=self.layout,
item_map=self.item_map, item_map=self.item_map,
metadata=item.metadata,
) )
# TODO: test this # TODO: test this
rendered = templater.render_item(item, raw_html) rendered = templater.render_item(item, raw_html)
@ -208,28 +121,7 @@ class ZonaBuilder:
util.copy_static_file(item.source, dst) util.copy_static_file(item.source, dst)
def build(self): def build(self):
# clean output if applicable
if (
self.config.build.clean_output_dir
and self.layout.output.is_dir()
):
logger.debug("Removing stale 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()
if self.config.feed.enabled:
rss = self.generate_feed()
path = self.layout.output / self.config.feed.path
util.ensure_parents(path)
path.write_bytes(rss)
self.fresh = False

View file

@ -1,16 +1,59 @@
from importlib.metadata import version as __version__
from pathlib import Path
from typing import Annotated from typing import Annotated
import typer import typer
from pathlib import Path
from zona import server from zona import server
from zona.builder import ZonaBuilder from zona.builder import ZonaBuilder
from zona.layout import initialize_site from zona.layout import initialize_site
from zona.log import get_logger, setup_logging from zona.log import setup_logging
app = typer.Typer() app = typer.Typer()
logger = get_logger()
setup_logging("DEBUG")
@app.command()
def build(
root: Annotated[
Path | None,
typer.Argument(
help="Directory containing config.yml",
),
] = None,
output: Annotated[
Path | None,
typer.Argument(help="Location to write built website"),
] = None,
):
"""
Build the website.
Optionally specify the ROOT and OUTPUT directories.
"""
builder = ZonaBuilder(root, output)
builder.build()
@app.command()
def serve(
root: Annotated[
Path | None,
typer.Argument(
help="Directory containing config.yml",
),
] = None,
output: Annotated[
Path | None,
typer.Argument(help="Location to write built website"),
] = None,
):
"""
Build the website and start a live preview server.
The website is rebuilt when the source is modified.
Optionally specify the ROOT and OUTPUT directories.
"""
server.serve(root, output)
@app.command() @app.command()
@ -30,144 +73,8 @@ def init(
Optionally specify the ROOT directory. Optionally specify the ROOT directory.
""" """
logger.info("Initializing site...")
initialize_site(root) initialize_site(root)
@app.command()
def build(
root: Annotated[
Path | None,
typer.Argument(
help="Directory containing config.yml",
),
] = None,
output: Annotated[
Path | None,
typer.Option(
"--output", "-o", help="Location to write built website"
),
] = None,
draft: Annotated[
bool,
typer.Option("--draft", "-d", help="Include drafts."),
] = False,
):
"""
Build the website.
Optionally specify the ROOT and OUTPUT directories.
"""
if draft:
print("Option override: including drafts.")
builder = ZonaBuilder(
cli_root=root, cli_output=output, draft=draft
)
builder.build()
@app.command()
def serve(
root: Annotated[
Path | None,
typer.Argument(
help="Directory containing config.yml",
),
] = None,
host: Annotated[
str,
typer.Option(
"--host", help="Hostname for live preview server."
),
] = "localhost",
port: Annotated[
int,
typer.Option(
"--port",
"-p",
help="Port number for live preview server.",
),
] = 8000,
output: Annotated[
Path | None,
typer.Option(
"--output",
"-o",
help="Location to write built website. Temporary directory by default.",
),
] = None,
final: Annotated[
bool,
typer.Option("--final", "-f", help="Don't include drafts."),
] = False,
live_reload: Annotated[
bool | None,
typer.Option(
"--live-reload/--no-live-reload",
"-l/-L",
help="Automatically reload web preview. Overrides config.",
show_default=False,
),
] = None,
):
"""
Build the website and start a live preview server.
The website is rebuilt when the source is modified.
Optionally specify the ROOT and OUTPUT directories.
"""
if final:
print("Preview without drafts.")
else:
print("Preview with drafts.")
if live_reload is None:
reload = None
else:
reload = live_reload
server.serve(
root=root,
output=output,
draft=not final,
host=host,
port=port,
user_reload=reload,
)
def version_callback(value: bool):
if value:
print(f"Zona version: {__version__('zona')}")
raise typer.Exit()
@app.callback()
def main_entry(
version: Annotated[ # pyright: ignore[reportUnusedParameter]
bool | None,
typer.Option(
"--version",
callback=version_callback,
is_eager=True,
help="Print version info and exit.",
),
] = None,
verbosity: Annotated[
str,
typer.Option(
"--verbosity",
"-v",
help="Logging verbosity. One of INFO, DEBUG, WARN, ERROR.",
),
] = "info",
) -> None:
"""
Opinionated static site generator.
Supply --help after any subcommand for more details.
"""
setup_logging(verbosity)
def main(): def main():
app() app()

View file

@ -1,47 +1,22 @@
from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, tzinfo
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
import yaml
from dacite import Config as DaciteConfig
from dacite import from_dict from dacite import from_dict
import yaml
from zona.log import get_logger from pathlib import Path
logger = get_logger()
def find_config(start: Path | None = None) -> Path | None: def find_config(start: Path | None = None) -> Path | None:
logger.debug("Searching for config file...")
current = (start or Path.cwd()).resolve() current = (start or Path.cwd()).resolve()
for parent in [current, *current.parents]: for parent in [current, *current.parents]:
candidate = parent / "config.yml" candidate = parent / "zona.yml"
if candidate.is_file(): if candidate.is_file():
logger.debug(f"Config file {candidate} found.")
return candidate return candidate
logger.debug("Couldn't find config file.")
return None return None
SitemapConfig = dict[str, str]
@dataclass
class PostDefaultsConfig:
description: str = "A blog post"
@dataclass @dataclass
class BlogConfig: class BlogConfig:
dir: str = "blog" dir: str = "blog"
defaults: PostDefaultsConfig = field(
default_factory=PostDefaultsConfig
)
@dataclass @dataclass
@ -51,19 +26,17 @@ class HighlightingConfig:
wrap: bool = False wrap: bool = False
@dataclass
class LinksConfig:
external_new_tab: bool = True
@dataclass @dataclass
class MarkdownConfig: class MarkdownConfig:
image_labels: bool = True image_labels: bool = True
tab_length: int = 2
syntax_highlighting: HighlightingConfig = field( syntax_highlighting: HighlightingConfig = field(
default_factory=HighlightingConfig default_factory=HighlightingConfig
) )
links: LinksConfig = field(default_factory=LinksConfig)
@dataclass
class ThemeConfig:
name: str = "default"
@dataclass @dataclass
@ -72,69 +45,23 @@ class BuildConfig:
include_drafts: bool = False include_drafts: bool = False
@dataclass IGNORELIST = [".git", ".env", "*/.marksman.toml"]
class ReloadConfig:
enabled: bool = True
scroll_tolerance: int = 100
@dataclass
class ServerConfig:
reload: ReloadConfig = field(default_factory=ReloadConfig)
@dataclass
class AuthorConfig:
name: str = "John Doe"
email: str = "john@doe.net"
@dataclass
class FeedConfig:
enabled: bool = True
timezone: tzinfo = field(default_factory=lambda: ZoneInfo("UTC"))
path: str = "rss.xml"
link: str = "https://example.com"
title: str = "Zona Website"
description: str = "My zona website."
language: str = "en"
author: AuthorConfig = field(default_factory=AuthorConfig)
IGNORELIST = [".marksman.toml"]
def parse_timezone(s: Any) -> tzinfo:
if isinstance(s, str):
return ZoneInfo(s)
else:
raise TypeError(
f"Expected {str}, got {type(s)} for config key timezone"
)
@dataclass @dataclass
class ZonaConfig: class ZonaConfig:
base_url: str = "/" title: str = "Zona Blog"
feed: FeedConfig = field(default_factory=FeedConfig) base_url: str = "https://example.com"
# dictionary where key is name, value is url language: str = "en"
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)
theme: ThemeConfig = field(default_factory=ThemeConfig)
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":
with open(path, "r") as f: with open(path, "r") as f:
raw = yaml.safe_load(f) raw = yaml.safe_load(f)
config: ZonaConfig = from_dict( return from_dict(data_class=cls, data=raw)
data_class=cls,
data=raw,
config=DaciteConfig(type_hooks={tzinfo: parse_timezone}),
)
return config

View file

@ -1,523 +0,0 @@
:root {
--main-placeholder-color: #b14242;
--main-text-color: #b4b4b4;
--main-text-opaque-color: rgba(180, 180, 180, 0.8);
--main-bg-color: #121212;
--main-link-color: #df6464;
--main-heading-color: #df6464;
--main-bullet-color: #d87c4a;
--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 {
margin: 0;
line-height: 1.6;
font-size: 18px;
font-family: sans-serif;
background: var(--main-bg-color);
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;
font-size: 0.95em;
white-space: nowrap;
}
.post-nav .bar {
position: relative;
bottom: 0.05em;
display: inline-block;
width: 1px;
height: 0.8em;
background-color: currentColor;
vertical-align: middle;
margin: 0 0.3em;
}
.post-nav .placeholder {
color: var(--main-placeholder-color);
}
.post-nav .symbol {
color: var(--main-bullet-color);
margin: 0;
padding: 0;
display: inline;
}
.site-logo.hover-symbol::before {
content: "~/";
}
.title.hover-symbol::before {
content: "$";
}
.hover-symbol {
color: inherit;
position: relative;
font-weight: bold;
text-decoration: none;
transition: color 0.15s ease;
}
.hover-symbol::before {
font-family: monospace;
content: "#";
position: absolute;
right: 100%;
margin-right: 0.25em;
top: 50%;
transform: translateY(-50%);
opacity: 0;
transition: opacity 0.15s ease, color 0.15s ease;
color: var(--main-text-color);
}
.hover-symbol:hover::before {
opacity: 1;
color: var(--main-placeholder-color);
}
.hover-symbol:hover {
background-color: transparent;
}
.toc ul {
font-family: monospace;
text-transform: lowercase;
margin: auto;
width: 50%;
}
.toc ul ul {
padding-left: 1em;
margin-left: 1em;
/* list-style-type: " ";*/
}
.toc ul ul ul {
padding-left: 1em;
margin-left: 1em;
/* list-style-type: "-- ";*/
}
.toclink {
position: relative;
text-decoration: none;
color: inherit;
transition: color 0.15s ease;
text-transform: lowercase;
font-family: monospace;
}
.post-list a {
position: relative;
text-decoration: none;
transition: color 0.15s ease;
text-transform: lowercase;
font-family: monospace;
}
.toclink::before {
content: "#";
position: absolute;
right: 100%;
margin-right: 0.25em;
top: 50%;
transform: translateY(-50%);
opacity: 0;
transition: opacity 0.15s ease, color 0.15s ease;
color: var(--main-link-color);
}
.toclink:hover::before {
opacity: 1;
color: var(--main-placeholder-color);
}
.toclink:hover {
background-color: transparent;
}
h1 .toclink::before {
content: "#";
}
h2 .toclink::before {
content: "#";
}
h3 .toclink::before {
content: "##";
}
h4 .toclink::before {
content: "###";
}
/* h1, */
h2,
h3,
h4,
h5,
h6 {
color: var(--main-heading-color);
}
h1 {
margin-block-start: 0.67rem;
margin-block-end: 0.67rem;
font-size: 2rem;
font-weight: bold;
}
.title {
text-transform: lowercase;
font-family: monospace;
}
.title a {
color: inherit;
text-decoration: none;
}
article h1:first-of-type {
margin-block-start: 1.67rem;
}
h2 {
margin-block-start: 0.83rem;
margin-block-end: 0.83rem;
font-size: 1.5rem;
font-weight: bold;
}
h3 {
margin-block-start: 1rem;
margin-block-end: 1rem;
font-size: 1.17em;
font-weight: bold;
}
h4 {
margin-block-start: 1.33rem;
margin-block-end: 1.33rem;
font-size: 1rem;
font-weight: bold;
}
article h1 + h4:first-of-type {
margin-block-start: 0rem;
}
h5 {
margin-block-start: 1.67rem;
margin-block-end: 1.67rem;
font-size: 0.83rem;
font-weight: bold;
}
h6 {
margin-block-start: 2.33rem;
margin-block-end: 2.33rem;
font-size: 0.67rem;
font-weight: bold;
}
/*ul {*/
/* list-style-type: disc;*/
/*}*/
ul {
list-style-type: " ";
}
ul ul {
padding-left: 1em;
margin-left: 1em;
list-style-type: "+ ";
}
ul ul ul {
list-style-type: "~ ";
}
ul ul ul ul {
list-style-type: "• ";
}
ul ul ul ul ul {
list-style-type: " ";
}
ul ul ul ul ul ul {
list-style-type: "+ ";
}
ul ul ul ul ul ul ul {
list-style-type: "~ ";
}
ul ul ul ul ul ul ul ul {
list-style-type: "• ";
}
li::marker {
color: var(--main-bullet-color);
}
a {
color: var(--main-link-color);
text-decoration: underline;
text-decoration-color: rgba(0, 0, 0, 0);
text-underline-offset: 2px;
}
a {
transition: color 0.15s ease, text-decoration-color 0.15s ease;
}
a:hover {
text-decoration-color: var(--main-placeholder-color);
color: var(--main-bullet-color);
}
max-width: 100%;
overflow: hidden;
img {
display: block;
margin-left: auto;
margin-right: auto;
width: auto;
height: auto;
}
blockquote {
color: var(--main-text-opaque-color);
border-left: 3px solid var(--orange-rgb);
padding: 0 1rem;
margin-left: 0;
margin-right: 0;
}
hr {
border: none;
height: 1px;
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 {
background: var(--main-transparent);
border-radius: 0.1875rem;
/* padding: .0625rem .1875rem; */
/* margin: 0 .1875rem; */
}
code,
pre {
white-space: pre;
word-wrap: break-word;
overflow-wrap: break-word;
font-family:
ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
Liberation Mono,
monospace;
font-size: 0.95em;
}
pre {
background-color: #1d1d1d;
color: #d5d5d5;
padding: 1em;
border-radius: 5px;
line-height: 1.5;
overflow-x: auto;
}
/* Inline code styling */
:not(pre) > code {
padding: 0.2em 0.4em;
font-size: 0.85em;
line-height: 1;
background-color: #1d1d1d;
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) */
pre code {
padding: 0;
border-radius: 0;
background: none;
}
small {
font-size: 0.95rem;
color: var(--main-small-text-color);
}
small a {
color: inherit;
/* Inherit the color of the surrounding <small> text */
text-decoration: underline;
/* Optional: Keep the underline to indicate a link */
}
.title-container {
display: flex;
justify-content: center;
align-items: center;
text-align: center;
}
.title-container h1 {
margin: 0;
}
.image-container {
text-align: center;
margin: 20px 0;
max-width: 100%;
overflow: hidden;
/* Optional: add some spacing around the image container */
}
.image-container img {
max-width: 100%;
width: auto;
max-height: 100%;
height: auto;
}
.fixed .image-container img {
max-width: 308px;
max-height: 308px;
}
.image-container small {
display: block;
/* Ensure the caption is on a new line */
margin-top: 5px;
/* Optional: adjust spacing between image and caption */
}
.image-container small a {
color: inherit;
/* Ensure the link color matches the small text */
text-decoration: underline;
/* Optional: underline to indicate a link */
}
#header ul {
list-style-type: none;
padding-left: 0;
}
#header li {
display: inline;
font-size: 1.2rem;
margin-right: 1.2rem;
}
#container {
margin: 2.5rem auto;
width: 90%;
max-width: 60ch;
}
#postlistdiv ul {
list-style-type: none;
padding-left: 0;
}
.moreposts {
font-size: 0.95rem;
padding-left: 0.5rem;
}
#nextprev {
text-align: center;
margin-top: 1.4rem;
font-size: 0.95rem;
}
#footer {
color: var(--main-small-text-color);
}
table {
border-collapse: collapse;
margin: 1.5rem auto;
width: 100%;
max-width: 100%;
font-size: 0.85rem;
text-align: left; /* Use center if you prefer */
}
th, td {
border: 1px solid var(--main-transparent);
/*border: 1px solid var(--main-bullet-color);*/
padding: 0.4rem 0.8rem;
vertical-align: middle;
}
thead th {
font-weight: bold;
background-color: rgba(255, 255, 255, 0.05);
color: var(--main-text-color);
}
tbody tr:nth-child(even) {
background-color: rgba(255, 255, 255, 0.02);
}
tbody tr:hover {
background-color: rgba(255, 255, 255, 0.05);
}
table code {
font-family:
ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
Liberation Mono,
monospace;
font-size: 0.85em;
background: #1d1d1d;
padding: 0.1em 0.25em;
border-radius: 3px;
}
caption {
margin-top: 0.5rem;
font-size: 0.8rem;
color: var(--main-small-text-color);
}

View file

@ -1,25 +0,0 @@
(() => {
// 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

@ -10,12 +10,6 @@
type="text/css" type="text/css"
media="all" media="all"
/> />
<link
href="/static/pygments.css"
rel="stylesheet"
type="text/css"
media="all"
/>
</head> </head>
<body> <body>
<div id="container"> <div id="container">

View file

@ -1,10 +0,0 @@
{% extends "base.html" %}
{% block content %}
{% if metadata.show_title %}
{% include "title.html" %}
{% endif %}
{{ content | safe }}
{% endblock %}

View file

@ -1 +1 @@
My Markdown footer! The footer content.

View file

@ -1,5 +0,0 @@
<ul>
{% for name, url in site_map.items() %}
<li><a href="{{ url }}">{{ name }}</a></li>
{% endfor %}
</ul>

View file

@ -0,0 +1,2 @@
- One
- Two

View file

@ -1,12 +1,10 @@
{% extends "base.html" %} {% block content %} {% if metadata.show_title %} {% {% extends "base.html" %}
include "title.html" %} {% if metadata.date.date() %}
<center>
<time class="post-date" datetime="{{ metadata.date.date() | safe }}"
>{{ metadata.date.date() | safe}}</time>
</center>
{% endif %} {% endif %} {% if is_post %} {% include "post_nav.html" %} {% endif
%}
<hr>
{% block content %}
<center><h1>{{ metadata.title }}</h1></center>
{% 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,18 +1,13 @@
{% extends "base.html" %} {% block content %} {% if metadata.show_title %} {% {% extends "base.html" %}
include "title.html" %} {% endif %}
<article>{{ content | safe }}</article>
{% block content %}
<center><h1>{{ metadata.title }}</h1></center>
{% if post_list %} {% if post_list %}
<div class="post-list">
<ul> <ul>
{% for item in post_list %} {% for item in post_list %}
<li> <li><a href="/{{ item.url }}">{{ item.metadata.title }}</a></li>
<time class="post-list-date" datetime="{{ item.metadata.date.date() | safe }}"
>{{ item.metadata.date.date() | safe}}</time>: <a href="/{{ item.url }}"
>{{ item.metadata.title }}</a>
</li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> {% endif %}
{% endif %} {% endblock %} {% endblock %}

View file

@ -1,11 +0,0 @@
<div class="post-nav">
<center>
<span class="symbol">&lt;</span>{% if newer %}<a href="{{ newer }}">newr</a>{%
else %}<span class="placeholder">null</span>{% endif %}<span
class="symbol"
><span class="bar"></span></span>{% if older %}<a href="{{ older }}"
>oldr</a>{% else %}<span class="placeholder">null</span>{% endif %}<span
class="symbol"
>&gt;</span>
</center>
</div>

View file

@ -1,2 +0,0 @@
<center><h1 class="title hover-symbol"><a id="" href="#">{{ metadata.title }}</a></h1></center>

View file

@ -1,86 +1,32 @@
from dataclasses import asdict, dataclass
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo from dataclasses import dataclass, asdict
import typer
import yaml
from zona import log, util
from zona.config import ZonaConfig, find_config from zona.config import ZonaConfig, find_config
from zona import util
logger = log.get_logger() import yaml
@dataclass @dataclass
class Layout: class Layout:
root: Path root: Path
content: Path content: Path
templates: Path templates: Path | None
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(
cls, cls, root: Path, output: Path | None = None, validate: bool = True
root: Path,
output: Path | None = None,
validate: bool = True,
) -> "Layout": ) -> "Layout":
layout = cls( layout = cls(
root=root.resolve(), root=root.resolve(),
content=(root / "content").resolve(), content=(root / "content").resolve(),
templates=(root / "templates").resolve(), templates=(root / "templates").resolve(),
output=(root / "public").resolve() output=(root / "public").resolve() if not output else output,
if not output
else output,
shared_templates=None,
_validate=validate,
) )
if validate: if validate:
logger.debug("Validating site layout...")
if not layout.content.is_dir(): if not layout.content.is_dir():
logger.error("Missing required content directory!")
raise FileNotFoundError( raise FileNotFoundError(
"Missing required content directory!" "Missing required content directory!"
) )
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.")
# use the included defaults
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
@ -89,71 +35,44 @@ def discover_layout(
cli_root: Path | None = None, cli_output: Path | None = None cli_root: Path | None = None, cli_output: Path | None = None
) -> Layout: ) -> Layout:
if cli_root: if cli_root:
logger.debug("Using user provided site root.")
root = cli_root root = cli_root
else: else:
logger.debug("Discovering site layout...")
config = find_config(cli_root) config = find_config(cli_root)
if config: if config:
root = config.parent root = config.parent
else: else:
logger.debug("Using CWD as root.")
root = Path.cwd() root = Path.cwd()
return Layout.from_input(root, cli_output) return Layout.from_input(root, cli_output)
def initialize_site(root: Path | None = None): def initialize_site(root: Path | None = None):
logger.info("Initializing site.")
# initialize a new project # initialize a new project
if not root: if not root:
logger.debug("No root provided; using CWD.")
root = Path.cwd() root = Path.cwd()
root = root.absolute().resolve() root = root.absolute().resolve()
config = find_config(root) config = find_config(root)
if config is not None: if config is not None:
ans = typer.confirm( raise FileExistsError(f"Config file already exists at {config}")
text=(
f"A config file already exists at {config}.\n"
f"Delete it and restore defaults?"
)
)
if ans:
logger.debug("Unlinking config file.")
config.unlink()
# create requires layout # create requires layout
logger.debug("Generating layout.")
layout = Layout.from_input(root=root, validate=False) layout = Layout.from_input(root=root, validate=False)
# load template resources # load template resources
logger.debug("Loading internal templates.") templates = util.get_resources("templates")
# only write the footer
templates = [util.get_resource("templates/footer.md")]
logger.debug("Loading internal static content.")
static = util.get_resources("content")
for dir, resources in [ for dir, resources in [
(layout.root, None), (layout.root, None),
(layout.content, static), (layout.content, None),
(layout.templates, templates), (layout.templates, templates),
]: ]:
if not dir.is_dir(): if not dir.is_dir():
logger.debug(f"Creating {dir}.")
dir.mkdir() dir.mkdir()
if resources is not None: if resources is not None:
logger.debug("Writing resources.")
for r in resources: for r in resources:
logger.debug(f"Writing {(root / Path(r.name))}.") Path(r.name).write_text(r.contents)
(root / Path(r.name)).write_text(r.contents)
config_path = layout.root / "config.yml" config_path = layout.root / "config.yml"
logger.debug("Loading default configuation.")
config = ZonaConfig() config = ZonaConfig()
logger.debug(f"Writing default configuration to {config_path}.")
config_dict = asdict(config)
if "feed" in config_dict and "timezone" in config_dict["feed"]:
tz: ZoneInfo = config_dict["feed"]["timezone"]
config_dict["feed"]["timezone"] = tz.key
with open(config_path, "w") as f: with open(config_path, "w") as f:
yaml.dump( yaml.dump(
config_dict, asdict(config),
f, f,
sort_keys=False, sort_keys=False,
default_flow_style=False, default_flow_style=False,

View file

@ -1,12 +1,12 @@
import logging import logging
from typing import Literal
from rich.logging import RichHandler from rich.logging import RichHandler
_LOGGER_NAME = "zona" _LOGGER_NAME = "zona"
def setup_logging( def setup_logging(
level: str = "INFO", level: Literal["INFO", "DEBUG", "WARN", "ERROR"] = "INFO",
): ):
logger = logging.getLogger(_LOGGER_NAME) logger = logging.getLogger(_LOGGER_NAME)
logger.setLevel(level.upper()) logger.setLevel(level.upper())

View file

@ -1,80 +1,25 @@
import xml.etree.ElementTree as etree from rich import print
from collections.abc import Sequence
from logging import Logger
from pathlib import Path
from typing import Any, override from typing import Any, override
from pathlib import Path
from l2m4m import LaTeX2MathMLExtension from marko.inline import Link, Image
from markdown import Markdown from marko.block import FencedCode
from markdown.extensions.abbr import AbbrExtension from marko.html_renderer import HTMLRenderer
from markdown.extensions.attr_list import AttrListExtension from marko.parser import Parser
from markdown.extensions.codehilite import CodeHiliteExtension
from markdown.extensions.def_list import DefListExtension
from markdown.extensions.footnotes import FootnoteExtension
from markdown.extensions.md_in_html import MarkdownInHtmlExtension
from markdown.extensions.sane_lists import SaneListExtension
from markdown.extensions.smarty import SmartyExtension
from markdown.extensions.tables import TableExtension
from markdown.extensions.toc import TocExtension
from markdown.treeprocessors import Treeprocessor
from pygments.formatters.html import HtmlFormatter
from pymdownx.betterem import BetterEmExtension
from pymdownx.caret import InsertSupExtension
from pymdownx.escapeall import EscapeAllExtension
from pymdownx.inlinehilite import InlineHiliteExtension
from pymdownx.smartsymbols import SmartSymbolsExtension
from pymdownx.superfences import SuperFencesCodeExtension
from pymdownx.tilde import DeleteSubExtension
from zona import util
from zona.config import ZonaConfig from zona.config import ZonaConfig
from zona.layout import Layout from zona.layout import Layout
from zona.log import get_logger
from zona.metadata import Metadata from pygments import highlight
from pygments.lexers import get_lexer_by_name, TextLexer
from pygments.formatters import HtmlFormatter
from zona import util
from zona.models import Item from zona.models import Item
from zona.log import get_logger
logger = get_logger()
class ZonaImageTreeprocessor(Treeprocessor): class ZonaRenderer(HTMLRenderer):
"""Implement Zona's image caption rendering."""
def __init__(self, md: Markdown):
super().__init__()
self.md: Markdown = md
self.logger: Logger = get_logger()
@override
def run(self, root: etree.Element):
for parent in root.iter():
for idx, child in enumerate(list(parent)):
if (
child.tag == "p"
and len(child) == 1
and child[0].tag == "img"
):
img = child[0]
div = etree.Element(
"div", {"class": "image-container"}
)
div.append(img)
title = img.attrib.get("alt", "")
if title:
raw_caption = self.md.convert(title)
caption_html = raw_caption.strip()
if caption_html.startswith(
"<p>"
) and caption_html.endswith("</p>"):
caption_html = caption_html[3:-4]
caption = etree.Element("small")
caption.text = "" # should be rendered
caption_html_element = etree.fromstring(
f"<span>{caption_html}</span>"
)
caption.append(caption_html_element)
div.append(caption)
parent[idx] = div
class ZonaLinkTreeprocessor(Treeprocessor):
def __init__( def __init__(
self, self,
config: ZonaConfig | None, config: ZonaConfig | None,
@ -85,7 +30,6 @@ class ZonaLinkTreeprocessor(Treeprocessor):
): ):
super().__init__() super().__init__()
self.resolve: bool = resolve self.resolve: bool = resolve
self.logger: Logger = get_logger()
if self.resolve: if self.resolve:
assert source is not None assert source is not None
assert layout is not None assert layout is not None
@ -96,66 +40,74 @@ class ZonaLinkTreeprocessor(Treeprocessor):
self.config: ZonaConfig | None = config self.config: ZonaConfig | None = config
@override @override
def run(self, root: etree.Element): def render_link(self, element: Link):
for element in root.iter("a"): href = element.dest
href = element.get("href") assert isinstance(href, str)
if not href: if self.resolve:
continue cur = Path(href)
if self.resolve: _href = href
assert self.config if href.startswith("/"):
cur = Path(href) # resolve relative to content root
_href = href resolved = (
same_file = False self.layout.content / cur.relative_to("/")
resolved = Path() ).resolve()
# href starting with anchor reference the current file else:
if href.startswith("#"): # treat as relative link and try to resolve
same_file = True resolved = (self.source.parent / cur).resolve()
elif href.startswith("/"): # only substitute if link points to an actual file
# resolve relative to content root if resolved.exists():
resolved = ( item = self.item_map.get(resolved)
self.layout.content / cur.relative_to("/") if item:
).resolve() href = util.normalize_url(item.url)
logger.debug(
f"Link in file {self.source}: {_href} resolved to {href}"
)
else: else:
# treat as relative link and try to resolve logger.debug(
resolved = (self.source.parent / cur).resolve() f"Warning: resolved path {resolved} not found in item map"
# check if the link is internal )
internal = same_file body: Any = self.render_children(element)
if not same_file: return f'<a href="{href}" target="_blank">{body}</a>'
for suffix in {".md", ".html"}:
if resolved.with_suffix(suffix).exists():
internal = True
resolved = resolved.with_suffix(suffix)
break
# only substitute if link points to an actual file
# that isn't the self file
if not same_file and internal:
item = self.item_map.get(resolved)
if item:
href = util.normalize_url(item.url)
# don't sub if it's already correct lol
if _href != href:
element.set("href", href)
self.logger.debug(
f"Link in file {self.source}: {_href} resolved to {href}"
)
else:
self.logger.debug(
f"Warning: resolved path {resolved} not found in item map"
)
# open link in new tab if not self-link
elif (
self.config.markdown.links.external_new_tab
and not same_file
):
element.set("target", "_blank")
# TODO: image compression/dithering?
@override
def render_image(self, element: Image):
# get label text from children
text = self.render_children(element)
title = element.title or ""
caption = f"<small>{text}</small>" if text else ""
return (
f'<div class="image-container">\n'
# TODO: convert to plaintext and add as alt attribute
f'<img src="{element.dest}" title="{title}">\n'
f"{caption}</div>"
)
def get_formatter(config: ZonaConfig): @override
c = config.markdown.syntax_highlighting def render_fenced_code(self, element: FencedCode):
formatter = HtmlFormatter( assert self.config
style=c.theme, nowrap=not c.wrap, nobackground=True config = self.config.markdown.syntax_highlighting
) code = "".join(child.children for child in element.children) # type: ignore
return formatter lang = element.lang or "text"
if not config.enabled:
return f"<pre><code>{code}</code></pre>"
try:
lexer = get_lexer_by_name(lang, stripall=False)
except Exception:
lexer = TextLexer(stripall=False) # type: ignore
formatter = HtmlFormatter(
style=config.theme,
nowrap=not config.wrap,
noclasses=True,
)
highlighted = highlight(code, lexer, formatter) # type: ignore
return (
f'<pre class="code-block language-{lang}">'
f"<code>{highlighted}</code></pre>"
)
def md_to_html( def md_to_html(
@ -165,71 +117,20 @@ def md_to_html(
source: Path | None = None, source: Path | None = None,
layout: Layout | None = None, layout: Layout | None = None,
item_map: dict[Path, Item] | None = None, item_map: dict[Path, Item] | None = None,
metadata: Metadata | None = None,
) -> str: ) -> str:
extensions: Sequence[Any] = [ if resolve_links and (
BetterEmExtension(), source is None or layout is None or item_map is None
SuperFencesCodeExtension( ):
disable_indented_code_blocks=True, raise TypeError(
css_class="codehilite", "md_to_html() missing source and ctx when resolve_links is true"
),
FootnoteExtension(),
AttrListExtension(),
DefListExtension(),
TocExtension(
anchorlink=True,
),
TableExtension(),
AbbrExtension(),
SmartyExtension(),
InsertSupExtension(),
DeleteSubExtension(),
SmartSymbolsExtension(),
SaneListExtension(),
MarkdownInHtmlExtension(),
EscapeAllExtension(hardbreak=True),
]
kwargs: dict[str, Any] = {
"extensions": extensions,
"tab_length": 2,
}
if metadata and metadata.math:
kwargs["extensions"].append(LaTeX2MathMLExtension())
if config:
kwargs["extensions"].extend(
[
CodeHiliteExtension(
linenums=False,
noclasses=False,
pygments_style=config.markdown.syntax_highlighting.theme,
),
InlineHiliteExtension(css_class="codehilite"),
]
) )
kwargs["tab_length"] = config.markdown.tab_length parser = Parser()
md = Markdown(**kwargs) ast = parser.parse(content)
if resolve_links: renderer = ZonaRenderer(
if source is None or layout is None or item_map is None: config,
raise TypeError( resolve_links,
"md_to_html() missing source and ctx when resolve_links is true" source,
) layout=layout,
md.treeprocessors.register( item_map=item_map,
item=ZonaLinkTreeprocessor(
config, resolve_links, source, layout, item_map
),
name="zona_links",
priority=15,
)
md.treeprocessors.register(
item=ZonaImageTreeprocessor(md),
name="zona_images",
priority=17,
) )
return md.convert(content) return renderer.render(ast)
def get_style_defs(config: ZonaConfig) -> str:
formatter = get_formatter(config)
defs = formatter.get_style_defs(".codehilite")
assert isinstance(defs, str)
return defs

View file

@ -1,59 +1,29 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date, datetime, time, tzinfo from rich import print
from pathlib import Path from pathlib import Path
from datetime import date
import frontmatter
from dacite.config import Config from dacite.config import Config
from dacite.core import from_dict from dacite.core import from_dict
from dacite.exceptions import DaciteError from dacite.exceptions import DaciteError
from dateutil import parser as date_parser
from yaml import YAMLError from yaml import YAMLError
import zona.util import zona.util
from zona.config import ZonaConfig import frontmatter
@dataclass @dataclass
class Metadata: class Metadata:
title: str title: str
date: datetime date: date
description: str description: str | None
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 = "page.html"
post: bool | None = None post: bool | None = None
draft: bool = False
ignore: bool = False
math: bool = True
def ensure_timezone(dt: datetime, tz: tzinfo) -> datetime: def parse_metadata(path: Path) -> tuple[Metadata, str]:
if dt.tzinfo is None or dt.utcoffset() is None:
dt = dt.replace(tzinfo=tz)
return dt
# TODO: migrate to using datetime, where user can optionall specify
# a time as well. if only date is given, default to time.min
def parse_date(
raw_date: str | datetime | date | object, tz: tzinfo
) -> datetime:
if isinstance(raw_date, datetime):
return ensure_timezone(raw_date, tz)
elif isinstance(raw_date, date):
return datetime.combine(raw_date, time.min, tzinfo=tz)
assert isinstance(raw_date, str)
dt = date_parser.parse(raw_date)
return ensure_timezone(dt, tz)
def parse_metadata(
path: Path, config: ZonaConfig
) -> tuple[Metadata, str]:
""" """
Parses a file and returns parsed Metadata and its content. Defaults Parses a file and returns parsed Metadata and its content. Defaults
are applied for missing fields. If there is no metadata, a Metadata are applied for missing fields. If there is no metadata, a Metadata
@ -69,11 +39,9 @@ def parse_metadata(
raw_meta = post.metadata or {} raw_meta = post.metadata or {}
defaults = { defaults = {
"title": zona.util.filename_to_title(path), "title": zona.util.filename_to_title(path),
"date": datetime.fromtimestamp(path.stat().st_ctime), "date": date.fromtimestamp(path.stat().st_mtime),
"description": config.blog.defaults.description,
} }
meta = {**defaults, **raw_meta} meta = {**defaults, **raw_meta}
meta["date"] = parse_date(meta.get("date"), config.feed.timezone)
try: try:
metadata = from_dict( metadata = from_dict(
data_class=Metadata, data_class=Metadata,

View file

@ -1,8 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from pathlib import Path from pathlib import Path
from enum import Enum
from dataclasses import dataclass
from zona.metadata import Metadata from zona.metadata import Metadata
@ -23,8 +21,6 @@ class Item:
type: ItemType | None = None type: ItemType | None = None
copy: bool = True copy: bool = True
post: bool = False post: bool = False
newer: Item | None = None
older: Item | None = None
# @dataclass # @dataclass

View file

@ -1,124 +1,26 @@
import io
import os
import signal import signal
import os
import sys import sys
import tempfile
import threading
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from types import FrameType from types import FrameType
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
import threading
from typing import override from typing import override
from rich import print
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 watchdog.events import FileSystemEventHandler, FileSystemEvent
from pathlib import Path
from zona.log import get_logger from zona.log import get_logger
from zona.websockets import WebSocketServer
logger = get_logger() logger = get_logger()
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."""
js = util.get_resource("server/inject.js").contents
js = util.minify_js(js)
address = f"ws://{host}:{port}"
for placeholder, value in (
("__SOCKET_ADDRESS__", address),
("__SCROLL_TOLERANCE__", scroll_tolerance),
):
if placeholder not in js:
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):
"""Build the live reload handler with the script as an attribute."""
class CustomHandler(LiveReloadHandler):
pass
CustomHandler.script = script
return CustomHandler
class LiveReloadHandler(SimpleHTTPRequestHandler):
"""
Request handler implementing live reloading.
All logs are suppressed.
HTML files have the reload script injected before </body>.
"""
script: str = ""
@override
def log_message(self, format, *args): # type: ignore
pass
@override
def send_head(self):
path = Path(self.translate_path(self.path))
# check if serving path/index.html
if path.is_dir():
index_path = path / "index.html"
if index_path.is_file():
path = index_path
# check if serving html file
if path.suffix in {".html", ".htm"} and self.script != "":
try:
logger.debug(f"Injecting reload script: {path}")
# read the html
with open(path, "rb") as f:
content = f.read().decode("utf-8")
# inject script at the end of body
if r"</body>" in content:
content = content.replace(
"</body>", self.script + "</body>"
)
else:
# if no </body>, add to the end
content += self.script
# reencode, prepare headers, serve file
encoded = content.encode("utf-8")
self.send_response(200)
self.send_header(
"Content-type", "text/html; charset=utf-8"
)
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
return io.BytesIO(encoded)
except Exception:
self.send_error(404, "File not found")
return None
return super().send_head()
class QuietHandler(SimpleHTTPRequestHandler): class QuietHandler(SimpleHTTPRequestHandler):
"""SimpleHTTPRequestHandler with logs suppressed."""
@override @override
def log_message(self, format, *args): # type: ignore def log_message(self, format, *args): # type: ignore
pass pass
class ZonaServer(ThreadingHTTPServer): class ZonaServer(ThreadingHTTPServer):
"""HTTP server implementing live reloading via a WebSocket server.
Suppresses BrokenPipeError and ConnectionResetError.
"""
ws_server: WebSocketServer | None = None
def set_ws_server(self, ws_server: WebSocketServer):
self.ws_server = ws_server
@override @override
def handle_error(self, request, client_address): # type: ignore def handle_error(self, request, client_address): # type: ignore
_, exc_value = sys.exc_info()[:2] _, exc_value = sys.exc_info()[:2]
@ -129,33 +31,12 @@ class ZonaServer(ThreadingHTTPServer):
class ZonaReloadHandler(FileSystemEventHandler): class ZonaReloadHandler(FileSystemEventHandler):
"""FileSystemEventHandler that rebuilds the website def __init__(self, builder: ZonaBuilder, output: Path):
and triggers the browser into refreshing over WebSocket."""
def __init__(
self,
builder: ZonaBuilder,
output: Path,
ws_server: WebSocketServer | None,
):
self.builder: ZonaBuilder = builder self.builder: ZonaBuilder = builder
self.output: Path = output.resolve() self.output: Path = output.resolve()
self.ws_server: WebSocketServer | None = ws_server
def _trigger_rebuild(self, event: FileSystemEvent):
# check if it's an event we care about
if not self._should_ignore(event):
logger.info(f"Modified: {event.src_path}, rebuilding...")
# rebuild static site
self.builder.build()
if self.ws_server:
# trigger browser refresh
self.ws_server.notify_all()
def _should_ignore(self, event: FileSystemEvent) -> bool: def _should_ignore(self, event: FileSystemEvent) -> bool:
path = Path(str(event.src_path)).resolve() path = Path(str(event.src_path)).resolve()
# ignore if the output directory has been changed
# to avoid infinite loop
return ( return (
self.output in path.parents self.output in path.parents
or path == self.output or path == self.output
@ -164,97 +45,56 @@ class ZonaReloadHandler(FileSystemEventHandler):
@override @override
def on_modified(self, event: FileSystemEvent): def on_modified(self, event: FileSystemEvent):
self._trigger_rebuild(event) if not self._should_ignore(event):
logger.info(f"Modified: {event.src_path}, rebuilding...")
self.builder.build()
@override @override
def on_created(self, event: FileSystemEvent): def on_created(self, event: FileSystemEvent):
self._trigger_rebuild(event) if not self._should_ignore(event):
logger.info(f"Modified: {event.src_path}, rebuilding...")
self.builder.build()
def run_http_server(dir: Path, host: str = "localhost", port: int = 8000):
os.chdir(dir)
handler = QuietHandler
httpd = ZonaServer(
server_address=(host, port), RequestHandlerClass=handler
)
logger.info(f"Serving {dir} at http://{host}:{port}")
logger.info(f"Exit with <c-c>")
httpd.serve_forever()
def serve( def serve(
root: Path | None = None, root: Path | None = None,
output: Path | None = None, output: Path | None = None,
draft: bool = True,
host: str = "localhost", host: str = "localhost",
port: int = 8000, port: int = 8000,
user_reload: bool | None = None,
): ):
"""Serve preview website with live reload and automatic rebuild.""" builder = ZonaBuilder(root, output)
# create temp dir, automatic cleanup builder.build()
with tempfile.TemporaryDirectory() as tmp: if output is None:
builder = ZonaBuilder(root, Path(tmp), draft) output = builder.layout.output
config = builder.config if root is None:
# initial site build root = builder.layout.root
builder.build()
# use discovered paths if none provided
if output is None:
output = builder.layout.output
if root is None:
root = builder.layout.root
# use config value unless overridden by user server_thread = threading.Thread(
reload = config.server.reload.enabled target=run_http_server, args=(output, host, port), daemon=True
if user_reload is not None: )
reload = user_reload server_thread.start()
if reload:
print("Live reloading is enabled.")
# spin up websocket server for live reloading
ws_port = port + 1
ws_server = WebSocketServer(host, ws_port)
ws_server.start()
# generate reload script for injection
scroll_tolerance = config.server.reload.scroll_tolerance
reload_script = make_reload_script(
host, ws_port, scroll_tolerance
)
# generate handler with reload script as attribute
handler = make_handler_class(reload_script)
else:
handler = QuietHandler
ws_server = None
# serve the output directory
os.chdir(output)
# initialize http server
httpd = ZonaServer(
server_address=(host, port), RequestHandlerClass=handler
)
# link websocket server
if ws_server:
httpd.set_ws_server(ws_server)
# provide link to user
print(f"Serving {output} at http://{host}:{port}")
print("Exit with <c-c>")
# start server in a thread event_handler = ZonaReloadHandler(builder, output)
server_thread = threading.Thread( observer = Observer()
target=httpd.serve_forever, daemon=True observer.schedule(event_handler, path=str(root), recursive=True)
) observer.start()
server_thread.start()
# initialize reload handler def shutdown_handler(_a: int, _b: FrameType | None):
event_handler = ZonaReloadHandler(builder, output, ws_server) logger.info("Shutting down...")
observer = Observer() observer.stop()
observer.schedule(
event_handler, path=str(root / "content"), recursive=True
)
templates = root / "templates"
if templates.is_dir():
observer.schedule(
event_handler,
path=str(templates),
recursive=True,
)
observer.start()
# function to shut down gracefully signal.signal(signal.SIGINT, shutdown_handler)
def shutdown_handler(_a: int, _b: FrameType | None): signal.signal(signal.SIGTERM, shutdown_handler)
print("Shutting down...")
observer.stop()
httpd.shutdown()
# register shutdown handler observer.join()
signal.signal(signal.SIGINT, shutdown_handler)
signal.signal(signal.SIGTERM, shutdown_handler)
# start file change watcher
observer.join()

View file

@ -1,72 +1,79 @@
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
from jinja2 import Environment, FileSystemLoader, select_autoescape from jinja2 import Environment, FileSystemLoader, select_autoescape
from jinja2.environment import Template
from zona import util
from zona.config import ZonaConfig
from zona.markdown import md_to_html
from zona.models import Item from zona.models import Item
from zona.markdown import md_to_html
from zona import util
def get_header(template_dir: Path) -> str | None: def get_header(root: Path) -> str | None:
md_header = template_dir / "header.md" md_header = root / "header.md"
html_header = template_dir / "header.html" html_header = root / "header.html"
if md_header.exists(): if md_header.exists():
return md_to_html(md_header.read_text(), None) return md_to_html(md_header.read_text(), None)
elif html_header.exists(): elif html_header.exists():
return html_header.read_text() return html_header.read_text()
def get_footer(template_dir: Path) -> str | None: def get_footer(root: Path) -> str | None:
md_footer = template_dir / "footer.md" md_footer = root / "footer.md"
html_footer = template_dir / "footer.html" html_footer = root / "footer.html"
if md_footer.exists(): if md_footer.exists():
return md_to_html(md_footer.read_text(), None) return md_to_html(md_footer.read_text(), None)
elif html_footer.exists(): elif html_footer.exists():
return html_footer.read_text() return html_footer.read_text()
class TemplateEnv:
def __init__(self, user_dir: Path | None):
default_path = util.get_resource_dir("templates")
self.default: Environment = Environment(
loader=FileSystemLoader(default_path),
autoescape=select_autoescape(["html", "xml"]),
)
self.user: Environment | None = (
Environment(
loader=FileSystemLoader(user_dir),
autoescape=select_autoescape(["html", "xml"]),
)
if user_dir
else None
)
def get_template(self, name: str) -> Template:
for env in (
[self.user, self.default] if self.user else [self.default]
):
if name in env.list_templates():
return env.get_template(name)
raise FileNotFoundError(f"Template {name} could not be found!")
# TODO: add next/prev post button logic to posts
# TODO: add a recent posts element that can be included elsewhere? # TODO: add a recent posts element that can be included elsewhere?
class Templater: class Templater:
def __init__( def __init__(
self, self, root: Path, template_dir: Path | None, post_list: list[Item]
config: ZonaConfig,
template_dir: Path,
post_list: list[Item],
): ):
# build temporary template dir self.env: TemplateEnv = TemplateEnv(template_dir)
self.env: Environment = Environment( self.root: Path = root
loader=FileSystemLoader(template_dir), self.header: str | None = get_header(self.root)
autoescape=select_autoescape(["html", "xml"]), self.footer: str | None = get_footer(self.root)
)
self.config: ZonaConfig = config
self.template_dir: Path = template_dir
self.footer: str | None = get_footer(template_dir)
self.post_list: list[Item] = post_list self.post_list: list[Item] = post_list
def render_header(self):
template = self.env.get_template("header.html")
return template.render(site_map=self.config.sitemap)
def render_item(self, item: Item, content: str) -> str: def render_item(self, item: Item, content: str) -> str:
env = self.env env = self.env
meta = item.metadata meta = item.metadata
assert meta is not None assert meta is not None
if meta.template is None: template = env.get_template(
if item.post: meta.template
template_name = "page.html" if meta.template.endswith(".html")
else: else meta.template + ".html"
template_name = "basic.html" )
else:
template_name = (
meta.template
if meta.template.endswith(".html")
else meta.template + ".html"
)
template = env.get_template(template_name)
header: str | Literal[False] = ( header: str | Literal[False] = (
self.render_header() if meta.header else False self.header if self.header and meta.header else False
) )
footer: str | Literal[False] = ( footer: str | Literal[False] = (
self.footer if self.footer and meta.footer else False self.footer if self.footer and meta.footer else False
@ -77,12 +84,5 @@ class Templater:
metadata=meta, metadata=meta,
header=header, header=header,
footer=footer, footer=footer,
is_post=item.post,
newer=util.normalize_url(item.newer.url)
if item.newer
else None,
older=util.normalize_url(item.older.url)
if item.older
else None,
post_list=self.post_list, post_list=self.post_list,
) )

View file

@ -1,36 +1,10 @@
import fnmatch from typing import NamedTuple
import re from rich import print
import shutil
import string
import tempfile
import weakref
from importlib import resources from importlib import resources
from importlib.resources.abc import Traversable import fnmatch
from pathlib import Path from pathlib import Path
from shutil import copy2 from shutil import copy2
from typing import Any, NamedTuple, override import string
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):
@ -38,36 +12,16 @@ 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] = []
base = resources.files("zona").joinpath("data", subdir) for resource in (
resources.files("zona").joinpath(f"data/{subdir}").iterdir()
def walk(trav: Traversable, prefix: str = ""): ):
for item in trav.iterdir(): out.append(
path = f"{prefix}{item.name}" ZonaResource(f"{subdir}/{resource.name}", resource.read_text())
if item.is_dir(): )
walk(item, prefix=f"{path}/") print(out)
else:
out.append(
ZonaResource(
name=f"{subdir}/{path}",
contents=item.read_text(),
)
)
walk(base)
return out return out
@ -90,15 +44,6 @@ 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("_", " ")
@ -111,31 +56,6 @@ def normalize_url(url: str) -> str:
return url return url
def should_ignore( def should_ignore(path: Path, patterns: list[str], base: Path) -> bool:
path: Path, patterns: list[str], base: Path
) -> bool:
rel_path = path.relative_to(base) rel_path = path.relative_to(base)
return any( return any(fnmatch.fnmatch(str(rel_path), pattern) for pattern in patterns)
fnmatch.fnmatch(str(rel_path), pattern)
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()

View file

@ -1,68 +0,0 @@
import asyncio
from threading import Thread
from websockets.legacy.server import WebSocketServerProtocol, serve
class WebSocketServer:
"""
Async WebSocket server for live reloading.
Notifies clients when they should reload.
"""
host: str
port: int
clients: set[WebSocketServerProtocol]
loop: asyncio.AbstractEventLoop | None
thread: Thread | None
def __init__(self, host: str = "localhost", port: int = 8765):
self.host = host
self.port = port
self.clients = set()
self.loop = None
self.thread = None
async def _handler(self, ws: WebSocketServerProtocol):
"""Handle incoming connections by adding to client set."""
self.clients.add(ws)
try:
await ws.wait_closed()
finally:
self.clients.remove(ws)
def start(self):
"""Spin up server."""
def run():
# set up async event loop
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
# start server
ws_server = serve(
ws_handler=self._handler,
host=self.host,
port=self.port,
)
# add server to event loop
self.loop.run_until_complete(ws_server)
self.loop.run_forever()
# spawn async serever in a thread
self.thread = Thread(target=run, daemon=True)
self.thread.start()
async def _broadcast(self, message: str):
"""Broadcast message to all connected clients."""
for ws in self.clients.copy():
try:
await ws.send(message)
except Exception:
self.clients.discard(ws)
def notify_all(self, message: str = "reload"):
"""Notify all connected clients."""
if self.loop and self.clients:
asyncio.run_coroutine_threadsafe(
coro=self._broadcast(message), loop=self.loop
)

View file

@ -1,10 +1,8 @@
from datetime import date
from pathlib import Path
import pytest import pytest
from datetime import date
from zona.builder import build, discover, split_metadata
from zona.metadata import Metadata from zona.metadata import Metadata
from zona.builder import split_metadata, discover, build
from pathlib import Path
def test_split_metadata(tmp_path: Path): def test_split_metadata(tmp_path: Path):

View file

@ -1,5 +1,4 @@
from pathlib import Path from pathlib import Path
from zona import util from zona import util

179
uv.lock generated
View file

@ -44,16 +44,6 @@ 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"
@ -75,77 +65,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
] ]
[[package]]
name = "l2m4m"
version = "1.0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "latex2mathml" },
{ name = "markdown" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/71/2548c288ef1b27f3419285e45430b8f10b9be8f4c34b4d2ecec2d34baf42/l2m4m-1.0.4.tar.gz", hash = "sha256:7fc451edb281604c1eb9d6fa626a8cce874e8a0d3641cca581c20b7544f51396", size = 16567, upload-time = "2024-06-15T16:49:04.798Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f6/8a/500639fe4b0b1dfc7775633b9173d96a474bbc0811578e916595c9b323f0/L2M4M-1.0.4-py3-none-any.whl", hash = "sha256:766e232b28cfdc6397e9c47162a938dba26e5ab90bddec8d82eccb7896ab46d1", size = 14985, upload-time = "2024-06-15T16:49:03.88Z" },
]
[[package]]
name = "latex2mathml"
version = "3.78.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/69/33/ad2c3929494ad160f5130ea132ca298627a6c81c70be6bedd1bc806b5b01/latex2mathml-3.78.0.tar.gz", hash = "sha256:712193aa4c6ade1a8e0145dac7bc1f9aafbd54f93046a2356a7e1c05fa0f8b31", size = 73737, upload-time = "2025-05-03T16:51:53.563Z" }
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" },
]
[[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]]
name = "markdown"
version = "3.8.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/c2/4ab49206c17f75cb08d6311171f2d65798988db4360c4d1485bd0eedd67c/markdown-3.8.2.tar.gz", hash = "sha256:247b9a70dd12e27f67431ce62523e675b866d254f900c4fe75ce3dda62237c45", size = 362071, upload-time = "2025-06-19T17:12:44.483Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/2b/34cc11786bc00d0f04d0f5fdc3a2b1ae0b6239eef72d3d345805f9ad92a1/markdown-3.8.2-py3-none-any.whl", hash = "sha256:5c83764dbd4e00bdd94d85a19b8d55ccca20fe35b2e678a1422b380324dd5f24", size = 106827, upload-time = "2025-06-19T17:12:42.994Z" },
]
[[package]] [[package]]
name = "markdown-it-py" name = "markdown-it-py"
version = "3.0.0" version = "3.0.0"
@ -158,6 +77,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
] ]
[[package]]
name = "marko"
version = "2.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/dc/c8cadbd83de1b38d95a48568b445a5553005ebdd32e00a333ca940113db4/marko-2.1.4.tar.gz", hash = "sha256:dd7d66f3706732bf8f994790e674649a4fd0a6c67f16b80246f30de8e16a1eac", size = 142795, upload-time = "2025-06-13T03:25:50.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/66/49e3691d14898fb6e34ccb337c7677dfb7e18269ed170f12e4b85315eae6/marko-2.1.4-py3-none-any.whl", hash = "sha256:81c2b9f570ca485bc356678d9ba1a1b3eb78b4a315d01f3ded25442fdc796990", size = 42186, upload-time = "2025-06-13T03:25:49.858Z" },
]
[[package]] [[package]]
name = "markupsafe" name = "markupsafe"
version = "3.0.2" version = "3.0.2"
@ -250,14 +178,14 @@ wheels = [
[[package]] [[package]]
name = "pygments-ashen" name = "pygments-ashen"
version = "0.1.3" version = "0.1.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pygments" }, { name = "pygments" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/0d/a4/e2eeeeb8211b3425eeeeaa49a1d94a3c8bfd6046ce2723897fe778f03198/pygments_ashen-0.1.3.tar.gz", hash = "sha256:2c69a931741f98fca531ef11d17bf690547f14a4382ec0fed7c7715a03aeaaf7", size = 5757, upload-time = "2025-07-04T16:46:51.879Z" } sdist = { url = "https://files.pythonhosted.org/packages/f2/11/5246b1ec9a14bbd32970773894ad3ea4a42569e1a72e90f8aff859b43f63/pygments_ashen-0.1.1.tar.gz", hash = "sha256:a39f8277fcfddc7f7c10fc5cfe8695b7fc4ede9776b2d25b2ad8a6b33af1bbd5", size = 5757, upload-time = "2025-06-25T16:59:54.725Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/be/2d/d9b1b57c4cd394459d6b1456efc6cdaba8c9b8b1fa4d9023c69ad846c7cf/pygments_ashen-0.1.3-py3-none-any.whl", hash = "sha256:c5553a9302d581743d928b834cae8f3f62ee52a7fd8b72b73f04796a1c71cef3", size = 4303, upload-time = "2025-07-04T16:46:49.849Z" }, { url = "https://files.pythonhosted.org/packages/49/27/f8a48a1a49c422309d6bcd1d2722a19dd90006777d1e972e4b1e128d081c/pygments_ashen-0.1.1-py3-none-any.whl", hash = "sha256:28fd415a38b0168a2134b96a09f34169197229a299ba4d781a8319fc5d7f5e67", size = 4301, upload-time = "2025-06-25T16:59:53.996Z" },
] ]
[[package]] [[package]]
@ -272,19 +200,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/35/e8/ccd661508989a0469ee296909f2d6cae8ac24304f9db9afe706f0ef5b8d5/pygments_kakoune-0.1.0-py3-none-any.whl", hash = "sha256:573a933b61c7c7993f52fd02a04b7a936558da2b5a6690728732f2c0a67221a9", size = 3606, upload-time = "2025-06-30T20:27:30.457Z" }, { url = "https://files.pythonhosted.org/packages/35/e8/ccd661508989a0469ee296909f2d6cae8ac24304f9db9afe706f0ef5b8d5/pygments_kakoune-0.1.0-py3-none-any.whl", hash = "sha256:573a933b61c7c7993f52fd02a04b7a936558da2b5a6690728732f2c0a67221a9", size = 3606, upload-time = "2025-06-30T20:27:30.457Z" },
] ]
[[package]]
name = "pymdown-extensions"
version = "10.16"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown" },
{ name = "pyyaml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1a/0a/c06b542ac108bfc73200677309cd9188a3a01b127a63f20cadc18d873d88/pymdown_extensions-10.16.tar.gz", hash = "sha256:71dac4fca63fabeffd3eb9038b756161a33ec6e8d230853d3cecf562155ab3de", size = 853197, upload-time = "2025-06-21T17:56:36.974Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/d4/10bb14004d3c792811e05e21b5e5dcae805aacb739bd12a0540967b99592/pymdown_extensions-10.16-py3-none-any.whl", hash = "sha256:f5dd064a4db588cb2d95229fc4ee63a1b16cc8b4d0e6145c0899ed8723da1df2", size = 266143, upload-time = "2025-06-21T17:56:35.356Z" },
]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "8.4.1" version = "8.4.1"
@ -301,18 +216,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" },
] ]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]] [[package]]
name = "python-frontmatter" name = "python-frontmatter"
version = "1.1.0" version = "1.1.0"
@ -398,15 +301,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
] ]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]] [[package]]
name = "typer" name = "typer"
version = "0.16.0" version = "0.16.0"
@ -476,57 +370,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
] ]
[[package]]
name = "websockets"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]
[[package]] [[package]]
name = "zona" name = "zona"
version = "1.2.1" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "dacite" }, { name = "dacite" },
{ name = "feedgen" },
{ name = "jinja2" }, { name = "jinja2" },
{ name = "l2m4m" }, { name = "marko" },
{ name = "markdown" },
{ name = "pygments" }, { name = "pygments" },
{ name = "pygments-ashen" }, { name = "pygments-ashen" },
{ name = "pygments-kakoune" }, { name = "pygments-kakoune" },
{ name = "pymdown-extensions" },
{ name = "python-dateutil" },
{ name = "python-frontmatter" }, { name = "python-frontmatter" },
{ name = "rich" }, { name = "rich" },
{ name = "typer" }, { name = "typer" },
{ name = "watchdog" }, { name = "watchdog" },
{ name = "websockets" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@ -540,20 +398,15 @@ 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 = "marko", specifier = ">=2.1.4" },
{ name = "markdown", specifier = ">=3.8.2" },
{ name = "pygments", specifier = ">=2.19.1" }, { name = "pygments", specifier = ">=2.19.1" },
{ name = "pygments-ashen", specifier = ">=0.1.3" }, { name = "pygments-ashen", specifier = ">=0.1.0" },
{ name = "pygments-kakoune", specifier = ">=0.1.0" }, { name = "pygments-kakoune", specifier = ">=0.1.0" },
{ name = "pymdown-extensions", specifier = ">=10.16" },
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
{ name = "python-frontmatter", specifier = ">=1.1.0" }, { name = "python-frontmatter", specifier = ">=1.1.0" },
{ name = "rich", specifier = ">=14.0.0" }, { name = "rich", specifier = ">=14.0.0" },
{ name = "typer", specifier = ">=0.16.0" }, { name = "typer", specifier = ">=0.16.0" },
{ name = "watchdog", specifier = ">=6.0.0" }, { name = "watchdog", specifier = ">=6.0.0" },
{ name = "websockets", specifier = ">=15.0.1" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]