Compare commits
99 commits
template-m
...
main
Author | SHA1 | Date | |
---|---|---|---|
66a3eb7f8c | |||
182b30a4ef | |||
312818d8a6 | |||
d8d1e991c2 | |||
828a82e5cb | |||
4f8979ae9b | |||
ac8c88e2af | |||
59948ec517 | |||
dea02e3a4e | |||
3dc623f1c7 | |||
4cef77e4e0 | |||
8ad8bad438 | |||
a0fb62ac7a | |||
d35d3f61fb | |||
4ea80a33f8 | |||
c33acfa1c8 | |||
bafe70ed37 | |||
fa10a813f2 | |||
c6dd2785af | |||
f72142fff3 | |||
115751b120 | |||
1f2b736815 | |||
c489a6f076 | |||
933210c93b | |||
404e951651 | |||
24919171ad | |||
de86a92928 | |||
2bc12ff2ed | |||
07152a3746 | |||
27178dc6d8 | |||
04948a9373 | |||
faac8f63bb | |||
e28a206067 | |||
8b849fd2a0 | |||
e69dd80875 | |||
db8318622c | |||
0e32024147 | |||
a789d1f64e | |||
46934e502f | |||
0166e82cce | |||
10d1772a2d | |||
fe0f338803 | |||
1c64d1f431 | |||
585a987c3f | |||
dacea2756a | |||
8d17572d12 | |||
bdd7558999 | |||
8793284bd6 | |||
71e541aa5e | |||
55df755596 | |||
0ee8094cc9 | |||
b8b8fef72c | |||
d739ef5d95 | |||
3037aa88d8 | |||
eacaeba2ab | |||
12f4670533 | |||
5bd9e26a49 | |||
11387b4481 | |||
d098641edb | |||
28b722ddf4 | |||
7fc481983d | |||
3b32352ba6 | |||
cefd57d8d7 | |||
a03e9bfb82 | |||
ecd3e50218 | |||
1221f43caf | |||
d8ca92dce6 | |||
2c7fcfc05b | |||
08e51665e0 | |||
7c52f28aa7 | |||
47be4986a5 | |||
85fa619828 | |||
99ad674f51 | |||
fc8897b71b | |||
da4e2620e9 | |||
db8d12991d | |||
4ad4696962 | |||
064bc8fc84 | |||
9b4e18d607 | |||
9f38b16d0c | |||
4eb2390707 | |||
2b5eef9839 | |||
941b993287 | |||
b26cfc2784 | |||
e890134abf | |||
d6d5f581c7 | |||
508fba5266 | |||
49b4242519 | |||
22a0a39d81 | |||
0940472410 | |||
c1e377c94d | |||
5ea472e014 | |||
c6cd90001a | |||
245919cb73 | |||
c875adb18c | |||
577976c741 | |||
f932f24e38 | |||
2876ef664b | |||
89e33e92c4 |
34 changed files with 2532 additions and 352 deletions
20
.forgejo/workflows/publish.yml
Normal file
20
.forgejo/workflows/publish.yml
Normal file
|
@ -0,0 +1,20 @@
|
|||
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 }}
|
||||
|
18
.forgejo/workflows/test.yml
Normal file
18
.forgejo/workflows/test.yml
Normal file
|
@ -0,0 +1,18 @@
|
|||
# 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
Normal file
25
.kakrc
Normal file
|
@ -0,0 +1,25 @@
|
|||
# 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}
|
||||
}
|
33
CHANGELOG.md
Normal file
33
CHANGELOG.md
Normal file
|
@ -0,0 +1,33 @@
|
|||
# 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
33
LICENSE
|
@ -1,13 +1,24 @@
|
|||
Copyright (c) 2025 Daniel Fichtinger <daniel@ficd.ca>
|
||||
Copyright (c) 2025 Daniel Fichtinger <daniel@ficd.sh>
|
||||
|
||||
Permission to use, copy, modify, and distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
3. Neither the name of the author nor the names of its contributors may
|
||||
be used to endorse or promote products derived from this software
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
|
||||
OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
|
||||
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||||
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
|
||||
OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGE.
|
||||
|
|
499
README.md
499
README.md
|
@ -1,12 +1,493 @@
|
|||
# Zona
|
||||
<h1>zona</h1>
|
||||
|
||||
This repository contains a Python rewrite of
|
||||
[zona](https://git.sr.ht/~ficd/zona). The project was increasing in complexity
|
||||
and in need of a refactor. I decided that I would rather implement the features
|
||||
in Python.
|
||||
[zona](https://git.ficd.sh/ficd/zona) is an _opinionated_ static site generator
|
||||
written in Python. From a structured directory of Markdown content, zona builds
|
||||
a simple static website. It's designed to get out of your way and let you focus
|
||||
on writing.
|
||||
|
||||
Once the rewrite is complete, this repository will be renamed to `zona`, and
|
||||
`zona` will become `zona-go` and archived.
|
||||
**What do I mean by opinionated?** I built zona primarily for myself. I've tried
|
||||
making it flexible by exposing as many variables as possible to the template
|
||||
engine. However, if you're looking for something stable, complete, and fully
|
||||
configurable, zona may not be for you. If you want a minimal Markdown blog and
|
||||
are comfortable with modifying `jinja2` templates and CSS, then you're in luck.
|
||||
|
||||
See the [tracker](https://todo.sr.ht/~ficd/zona) for updates on the status of
|
||||
the rewrite.
|
||||
For an example of a website built with zona, please see
|
||||
[ficd.sh](https://ficd.sh). For a list of known problems, see
|
||||
[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
|
||||

|
||||
```
|
||||
|
||||
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.
|
||||
|
|
22
justfile
22
justfile
|
@ -2,6 +2,28 @@
|
|||
default:
|
||||
@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:
|
||||
uv run ruff format
|
||||
|
||||
|
|
|
@ -1,25 +1,35 @@
|
|||
[project]
|
||||
name = "zona"
|
||||
version = "0.1.0"
|
||||
description = "Static site generator"
|
||||
version = "1.2.2"
|
||||
description = "Opinionated static site generator."
|
||||
license = "BSD-3-Clause "
|
||||
license-files = ["LICENSE"]
|
||||
readme = "README.md"
|
||||
authors = [
|
||||
{ name = "Daniel Fichtinger", email = "daniel@ficd.ca" }
|
||||
{ name = "Daniel Fichtinger", email = "daniel@ficd.sh" },
|
||||
]
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"dacite>=1.9.2",
|
||||
"jinja2>=3.1.6",
|
||||
"marko>=2.1.4",
|
||||
"pygments>=2.19.1",
|
||||
"pygments-ashen>=0.1.0",
|
||||
"pygments-kakoune>=0.1.0",
|
||||
"python-frontmatter>=1.1.0",
|
||||
"rich>=14.0.0",
|
||||
"typer>=0.16.0",
|
||||
"watchdog>=6.0.0",
|
||||
"dacite>=1.9.2",
|
||||
"feedgen>=1.0.0",
|
||||
"jinja2>=3.1.6",
|
||||
"l2m4m>=1.0.4",
|
||||
"markdown>=3.8.2",
|
||||
"pygments>=2.19.1",
|
||||
"pygments-ashen>=0.1.3",
|
||||
"pygments-kakoune>=0.1.0",
|
||||
"pymdown-extensions>=10.16",
|
||||
"python-dateutil>=2.9.0.post0",
|
||||
"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]
|
||||
zona = "zona.cli:main"
|
||||
|
||||
|
@ -38,9 +48,7 @@ exclude = [
|
|||
]
|
||||
executionEnvironments = [
|
||||
{ root = "src" },
|
||||
{ root = "tests", extraPaths = [
|
||||
"src",
|
||||
], reportPrivateUsage = false },
|
||||
{ root = "tests", extraPaths = ["src"], reportPrivateUsage = false },
|
||||
]
|
||||
# off | basic | standard | strict | recommended | all
|
||||
typeCheckingMode = "recommended"
|
||||
|
@ -50,12 +58,12 @@ reportUnusedCallResult = false
|
|||
reportCallInDefaultInitializer = false
|
||||
enableTypeIgnoreComments = true
|
||||
reportIgnoreCommentWithoutRule = false
|
||||
allowedUntypedLibraries = ["frontmatter", "pygments"]
|
||||
allowedUntypedLibraries = ["frontmatter", "pygments", "pymdownx", "l2m4m", "feedgen", "feedgen.feed"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 70
|
||||
indent-width = 4
|
||||
target-version = "py311"
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
fixable = ["ALL"]
|
||||
|
@ -68,7 +76,6 @@ quote-style = "double"
|
|||
indent-style = "space"
|
||||
docstring-code-format = true
|
||||
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = "src"
|
||||
testpaths = ["tests"]
|
||||
|
@ -80,8 +87,8 @@ filterwarnings = [
|
|||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"basedpyright>=1.29.4",
|
||||
"pytest>=8.4.0",
|
||||
"ruff>=0.11.13",
|
||||
"types-pygments>=2.19.0.20250516",
|
||||
"basedpyright>=1.29.4",
|
||||
"pytest>=8.4.0",
|
||||
"ruff>=0.11.13",
|
||||
"types-pygments>=2.19.0.20250516",
|
||||
]
|
||||
|
|
|
@ -1,14 +1,17 @@
|
|||
import shutil
|
||||
from datetime import date
|
||||
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.layout import Layout, discover_layout
|
||||
from zona.config import ZonaConfig
|
||||
from zona import util
|
||||
from pathlib import Path
|
||||
from rich import print
|
||||
|
||||
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.templates import Templater
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
@ -18,19 +21,25 @@ class ZonaBuilder:
|
|||
self,
|
||||
cli_root: 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.config: ZonaConfig = ZonaConfig.from_file(
|
||||
self.layout.root / "config.yml"
|
||||
)
|
||||
if draft:
|
||||
self.config.build.include_drafts = True
|
||||
self.items: list[Item] = []
|
||||
self.item_map: dict[Path, Item] = {}
|
||||
self.fresh: bool = True
|
||||
|
||||
def _discover(self):
|
||||
layout = self.layout
|
||||
items: list[Item] = []
|
||||
|
||||
base = layout.root / layout.content
|
||||
logger.debug(f"Discovering content in {base}.")
|
||||
for path in base.rglob("*"):
|
||||
if path.is_file() and not util.should_ignore(
|
||||
path, patterns=self.config.ignore, base=base
|
||||
|
@ -42,11 +51,21 @@ class ZonaBuilder:
|
|||
destination=destination,
|
||||
url=str(destination.relative_to(layout.output)),
|
||||
)
|
||||
if path.name.endswith(".md") and not path.is_relative_to(
|
||||
if path.name.endswith(
|
||||
".md"
|
||||
) and not path.is_relative_to(
|
||||
layout.root / "content" / "static"
|
||||
):
|
||||
item.metadata, item.content = parse_metadata(path)
|
||||
if item.metadata.post == True:
|
||||
logger.debug(f"Parsing {path.name}.")
|
||||
item.metadata, item.content = parse_metadata(
|
||||
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
|
||||
elif item.metadata.post is None:
|
||||
# check if in posts dir?
|
||||
|
@ -57,11 +76,13 @@ class ZonaBuilder:
|
|||
item.copy = False
|
||||
name = destination.stem
|
||||
if name == "index":
|
||||
item.destination = item.destination.with_suffix(
|
||||
".html"
|
||||
item.destination = (
|
||||
item.destination.with_suffix(".html")
|
||||
)
|
||||
else:
|
||||
relative = path.relative_to(base).with_suffix("")
|
||||
relative = path.relative_to(base).with_suffix(
|
||||
""
|
||||
)
|
||||
name = relative.stem
|
||||
item.destination = (
|
||||
layout.output
|
||||
|
@ -73,14 +94,55 @@ class ZonaBuilder:
|
|||
layout.output
|
||||
)
|
||||
item.url = (
|
||||
"" if rel_url == Path(".") else rel_url.as_posix()
|
||||
""
|
||||
if rel_url == Path(".")
|
||||
else rel_url.as_posix()
|
||||
)
|
||||
items.append(item)
|
||||
# print(item)
|
||||
self.items = items
|
||||
|
||||
def _build(self):
|
||||
def generate_feed(self) -> bytes:
|
||||
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
|
||||
# sort according to date
|
||||
# descending order
|
||||
post_list: list[Item] = sorted(
|
||||
[item for item in self.items if item.post],
|
||||
key=lambda item: item.metadata.date
|
||||
|
@ -88,13 +150,39 @@ class ZonaBuilder:
|
|||
else date.min,
|
||||
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(
|
||||
template_dir=self.layout.templates, post_list=post_list
|
||||
config=self.config,
|
||||
template_dir=self.layout.templates,
|
||||
post_list=post_list,
|
||||
)
|
||||
self.item_map = {
|
||||
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():
|
||||
dst = item.destination
|
||||
# print(item)
|
||||
|
@ -109,6 +197,7 @@ class ZonaBuilder:
|
|||
source=item.source,
|
||||
layout=self.layout,
|
||||
item_map=self.item_map,
|
||||
metadata=item.metadata,
|
||||
)
|
||||
# TODO: test this
|
||||
rendered = templater.render_item(item, raw_html)
|
||||
|
@ -119,7 +208,28 @@ class ZonaBuilder:
|
|||
util.copy_static_file(item.source, dst)
|
||||
|
||||
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...")
|
||||
self._discover()
|
||||
logger.debug("Building...")
|
||||
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
|
||||
|
|
193
src/zona/cli.py
193
src/zona/cli.py
|
@ -1,59 +1,16 @@
|
|||
from typing import Annotated
|
||||
import typer
|
||||
from importlib.metadata import version as __version__
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
|
||||
from zona import server
|
||||
from zona.builder import ZonaBuilder
|
||||
from zona.layout import initialize_site
|
||||
from zona.log import setup_logging
|
||||
from zona.log import get_logger, setup_logging
|
||||
|
||||
app = typer.Typer()
|
||||
|
||||
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)
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
@app.command()
|
||||
|
@ -73,8 +30,144 @@ def init(
|
|||
|
||||
Optionally specify the ROOT directory.
|
||||
"""
|
||||
logger.info("Initializing site...")
|
||||
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():
|
||||
app()
|
||||
|
|
|
@ -1,22 +1,47 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from dacite import from_dict
|
||||
import yaml
|
||||
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 zona.log import get_logger
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
def find_config(start: Path | None = None) -> Path | None:
|
||||
logger.debug("Searching for config file...")
|
||||
current = (start or Path.cwd()).resolve()
|
||||
|
||||
for parent in [current, *current.parents]:
|
||||
candidate = parent / "zona.yml"
|
||||
candidate = parent / "config.yml"
|
||||
if candidate.is_file():
|
||||
logger.debug(f"Config file {candidate} found.")
|
||||
return candidate
|
||||
logger.debug("Couldn't find config file.")
|
||||
return None
|
||||
|
||||
|
||||
SitemapConfig = dict[str, str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PostDefaultsConfig:
|
||||
description: str = "A blog post"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlogConfig:
|
||||
dir: str = "blog"
|
||||
defaults: PostDefaultsConfig = field(
|
||||
default_factory=PostDefaultsConfig
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
@ -27,16 +52,18 @@ class HighlightingConfig:
|
|||
|
||||
|
||||
@dataclass
|
||||
class MarkdownConfig:
|
||||
image_labels: bool = True
|
||||
syntax_highlighting: HighlightingConfig = field(
|
||||
default_factory=HighlightingConfig
|
||||
)
|
||||
class LinksConfig:
|
||||
external_new_tab: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class ThemeConfig:
|
||||
name: str = "default"
|
||||
class MarkdownConfig:
|
||||
image_labels: bool = True
|
||||
tab_length: int = 2
|
||||
syntax_highlighting: HighlightingConfig = field(
|
||||
default_factory=HighlightingConfig
|
||||
)
|
||||
links: LinksConfig = field(default_factory=LinksConfig)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
@ -45,23 +72,69 @@ class BuildConfig:
|
|||
include_drafts: bool = False
|
||||
|
||||
|
||||
IGNORELIST = [".git", ".env", "*/.marksman.toml"]
|
||||
@dataclass
|
||||
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
|
||||
class ZonaConfig:
|
||||
title: str = "Zona Blog"
|
||||
base_url: str = "https://example.com"
|
||||
language: str = "en"
|
||||
base_url: str = "/"
|
||||
feed: FeedConfig = field(default_factory=FeedConfig)
|
||||
# dictionary where key is name, value is url
|
||||
sitemap: SitemapConfig = field(
|
||||
default_factory=lambda: {"Home": "/"}
|
||||
)
|
||||
# list of globs relative to content that should be ignored
|
||||
ignore: list[str] = field(default_factory=lambda: IGNORELIST)
|
||||
markdown: MarkdownConfig = field(default_factory=MarkdownConfig)
|
||||
theme: ThemeConfig = field(default_factory=ThemeConfig)
|
||||
build: BuildConfig = field(default_factory=BuildConfig)
|
||||
blog: BlogConfig = field(default_factory=BlogConfig)
|
||||
server: ServerConfig = field(default_factory=ServerConfig)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: Path) -> "ZonaConfig":
|
||||
def from_file(cls, path: Path) -> ZonaConfig:
|
||||
with open(path, "r") as f:
|
||||
raw = yaml.safe_load(f)
|
||||
return from_dict(data_class=cls, data=raw)
|
||||
config: ZonaConfig = from_dict(
|
||||
data_class=cls,
|
||||
data=raw,
|
||||
config=DaciteConfig(type_hooks={tzinfo: parse_timezone}),
|
||||
)
|
||||
return config
|
||||
|
|
523
src/zona/data/content/static/style.css
Normal file
523
src/zona/data/content/static/style.css
Normal file
|
@ -0,0 +1,523 @@
|
|||
: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);
|
||||
}
|
25
src/zona/data/server/inject.js
Normal file
25
src/zona/data/server/inject.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
(() => {
|
||||
// if user at the bottom before reload, scroll to new bottom
|
||||
if (localStorage.getItem("wasAtBottom") === "1") {
|
||||
localStorage.removeItem("wasAtBottom");
|
||||
window.addEventListener("load", () => {
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const ws = new WebSocket("__SOCKET_ADDRESS__");
|
||||
const tol = __SCROLL_TOLERANCE__;
|
||||
ws.onmessage = event => {
|
||||
if (event.data === "reload") {
|
||||
// store flag if user currently at bottom
|
||||
const nearBottom = window.innerHeight + window.scrollY
|
||||
>= document.body.scrollHeight - tol;
|
||||
if (nearBottom) {
|
||||
localStorage.setItem("wasAtBottom", "1");
|
||||
}
|
||||
location.reload();
|
||||
}
|
||||
};
|
||||
})();
|
|
@ -10,6 +10,12 @@
|
|||
type="text/css"
|
||||
media="all"
|
||||
/>
|
||||
<link
|
||||
href="/static/pygments.css"
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
media="all"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container">
|
||||
|
|
10
src/zona/data/templates/basic.html
Normal file
10
src/zona/data/templates/basic.html
Normal file
|
@ -0,0 +1,10 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% if metadata.show_title %}
|
||||
{% include "title.html" %}
|
||||
{% endif %}
|
||||
{{ content | safe }}
|
||||
{% endblock %}
|
||||
|
||||
|
|
@ -1 +1 @@
|
|||
The footer content.
|
||||
My Markdown footer!
|
||||
|
|
5
src/zona/data/templates/header.html
Normal file
5
src/zona/data/templates/header.html
Normal file
|
@ -0,0 +1,5 @@
|
|||
<ul>
|
||||
{% for name, url in site_map.items() %}
|
||||
<li><a href="{{ url }}">{{ name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
|
@ -1,2 +0,0 @@
|
|||
- One
|
||||
- Two
|
|
@ -1,10 +1,12 @@
|
|||
{% extends "base.html" %}
|
||||
{% extends "base.html" %} {% block content %} {% if metadata.show_title %} {%
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
@ -1,13 +1,18 @@
|
|||
{% extends "base.html" %}
|
||||
{% extends "base.html" %} {% block content %} {% if metadata.show_title %} {%
|
||||
include "title.html" %} {% endif %}
|
||||
|
||||
<article>{{ content | safe }}</article>
|
||||
|
||||
{% block content %}
|
||||
<center><h1>{{ metadata.title }}</h1></center>
|
||||
{% if post_list %}
|
||||
<div class="post-list">
|
||||
<ul>
|
||||
{% for item in post_list %}
|
||||
<li><a href="/{{ item.url }}">{{ item.metadata.title }}</a></li>
|
||||
<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 %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
</div>
|
||||
{% endif %} {% endblock %}
|
||||
|
|
11
src/zona/data/templates/post_nav.html
Normal file
11
src/zona/data/templates/post_nav.html
Normal file
|
@ -0,0 +1,11 @@
|
|||
<div class="post-nav">
|
||||
<center>
|
||||
<span class="symbol"><</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"
|
||||
>></span>
|
||||
</center>
|
||||
</div>
|
2
src/zona/data/templates/title.html
Normal file
2
src/zona/data/templates/title.html
Normal file
|
@ -0,0 +1,2 @@
|
|||
<center><h1 class="title hover-symbol"><a id="" href="#">{{ metadata.title }}</a></h1></center>
|
||||
|
|
@ -1,9 +1,15 @@
|
|||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, asdict
|
||||
from zona.config import ZonaConfig, find_config
|
||||
from zona import util
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import typer
|
||||
import yaml
|
||||
|
||||
from zona import log, util
|
||||
from zona.config import ZonaConfig, find_config
|
||||
|
||||
logger = log.get_logger()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Layout:
|
||||
|
@ -11,23 +17,70 @@ class Layout:
|
|||
content: Path
|
||||
templates: 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
|
||||
def from_input(
|
||||
cls, root: Path, output: Path | None = None, validate: bool = True
|
||||
cls,
|
||||
root: Path,
|
||||
output: Path | None = None,
|
||||
validate: bool = True,
|
||||
) -> "Layout":
|
||||
layout = cls(
|
||||
root=root.resolve(),
|
||||
content=(root / "content").resolve(),
|
||||
templates=(root / "templates").resolve(),
|
||||
output=(root / "public").resolve() if not output else output,
|
||||
output=(root / "public").resolve()
|
||||
if not output
|
||||
else output,
|
||||
shared_templates=None,
|
||||
_validate=validate,
|
||||
)
|
||||
if validate:
|
||||
logger.debug("Validating site layout...")
|
||||
if not layout.content.is_dir():
|
||||
raise FileNotFoundError("Missing required content directory!")
|
||||
if not layout.templates.is_dir():
|
||||
logger.error("Missing required content directory!")
|
||||
raise FileNotFoundError(
|
||||
"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 = util.get_resource_dir("templates")
|
||||
layout.templates = internal_templates
|
||||
else:
|
||||
seen: set[str] = set()
|
||||
temp = util.TempDir()
|
||||
logger.debug(
|
||||
f"Creating shared template directory at {temp}"
|
||||
)
|
||||
for f in user_templates.iterdir():
|
||||
if f.is_file():
|
||||
util.copy_static_file(f, temp.path)
|
||||
seen.add(f.name)
|
||||
for f in internal_templates.iterdir():
|
||||
if f.is_file() and f.name not in seen:
|
||||
util.copy_static_file(f, temp.path)
|
||||
layout.shared_templates = temp
|
||||
layout.templates = temp.path
|
||||
|
||||
return layout
|
||||
|
||||
|
@ -36,44 +89,71 @@ def discover_layout(
|
|||
cli_root: Path | None = None, cli_output: Path | None = None
|
||||
) -> Layout:
|
||||
if cli_root:
|
||||
logger.debug("Using user provided site root.")
|
||||
root = cli_root
|
||||
else:
|
||||
logger.debug("Discovering site layout...")
|
||||
config = find_config(cli_root)
|
||||
if config:
|
||||
root = config.parent
|
||||
else:
|
||||
logger.debug("Using CWD as root.")
|
||||
root = Path.cwd()
|
||||
return Layout.from_input(root, cli_output)
|
||||
|
||||
|
||||
def initialize_site(root: Path | None = None):
|
||||
logger.info("Initializing site.")
|
||||
# initialize a new project
|
||||
if not root:
|
||||
logger.debug("No root provided; using CWD.")
|
||||
root = Path.cwd()
|
||||
root = root.absolute().resolve()
|
||||
config = find_config(root)
|
||||
if config is not None:
|
||||
raise FileExistsError(f"Config file already exists at {config}")
|
||||
ans = typer.confirm(
|
||||
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
|
||||
logger.debug("Generating layout.")
|
||||
layout = Layout.from_input(root=root, validate=False)
|
||||
# load template resources
|
||||
templates = util.get_resources("templates")
|
||||
logger.debug("Loading internal 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 [
|
||||
(layout.root, None),
|
||||
(layout.content, None),
|
||||
(layout.content, static),
|
||||
(layout.templates, templates),
|
||||
]:
|
||||
if not dir.is_dir():
|
||||
logger.debug(f"Creating {dir}.")
|
||||
dir.mkdir()
|
||||
if resources is not None:
|
||||
logger.debug("Writing resources.")
|
||||
for r in resources:
|
||||
Path(r.name).write_text(r.contents)
|
||||
logger.debug(f"Writing {(root / Path(r.name))}.")
|
||||
(root / Path(r.name)).write_text(r.contents)
|
||||
|
||||
config_path = layout.root / "config.yml"
|
||||
logger.debug("Loading default configuation.")
|
||||
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:
|
||||
yaml.dump(
|
||||
asdict(config),
|
||||
config_dict,
|
||||
f,
|
||||
sort_keys=False,
|
||||
default_flow_style=False,
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import logging
|
||||
from typing import Literal
|
||||
|
||||
from rich.logging import RichHandler
|
||||
|
||||
_LOGGER_NAME = "zona"
|
||||
|
||||
|
||||
def setup_logging(
|
||||
level: Literal["INFO", "DEBUG", "WARN", "ERROR"] = "INFO",
|
||||
level: str = "INFO",
|
||||
):
|
||||
logger = logging.getLogger(_LOGGER_NAME)
|
||||
logger.setLevel(level.upper())
|
||||
|
|
|
@ -1,25 +1,80 @@
|
|||
from rich import print
|
||||
from typing import Any, override
|
||||
import xml.etree.ElementTree as etree
|
||||
from collections.abc import Sequence
|
||||
from logging import Logger
|
||||
from pathlib import Path
|
||||
from marko.inline import Link, Image
|
||||
from marko.block import FencedCode
|
||||
from marko.html_renderer import HTMLRenderer
|
||||
from marko.parser import Parser
|
||||
from zona.config import ZonaConfig
|
||||
from zona.layout import Layout
|
||||
from typing import Any, override
|
||||
|
||||
from pygments import highlight
|
||||
from pygments.lexers import get_lexer_by_name, TextLexer
|
||||
from pygments.formatters import HtmlFormatter
|
||||
from l2m4m import LaTeX2MathMLExtension
|
||||
from markdown import Markdown
|
||||
from markdown.extensions.abbr import AbbrExtension
|
||||
from markdown.extensions.attr_list import AttrListExtension
|
||||
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.models import Item
|
||||
from zona.config import ZonaConfig
|
||||
from zona.layout import Layout
|
||||
from zona.log import get_logger
|
||||
|
||||
logger = get_logger()
|
||||
from zona.metadata import Metadata
|
||||
from zona.models import Item
|
||||
|
||||
|
||||
class ZonaRenderer(HTMLRenderer):
|
||||
class ZonaImageTreeprocessor(Treeprocessor):
|
||||
"""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__(
|
||||
self,
|
||||
config: ZonaConfig | None,
|
||||
|
@ -30,6 +85,7 @@ class ZonaRenderer(HTMLRenderer):
|
|||
):
|
||||
super().__init__()
|
||||
self.resolve: bool = resolve
|
||||
self.logger: Logger = get_logger()
|
||||
if self.resolve:
|
||||
assert source is not None
|
||||
assert layout is not None
|
||||
|
@ -40,74 +96,66 @@ class ZonaRenderer(HTMLRenderer):
|
|||
self.config: ZonaConfig | None = config
|
||||
|
||||
@override
|
||||
def render_link(self, element: Link):
|
||||
href = element.dest
|
||||
assert isinstance(href, str)
|
||||
if self.resolve:
|
||||
cur = Path(href)
|
||||
_href = href
|
||||
if href.startswith("/"):
|
||||
# resolve relative to content root
|
||||
resolved = (
|
||||
self.layout.content / cur.relative_to("/")
|
||||
).resolve()
|
||||
else:
|
||||
# treat as relative link and try to resolve
|
||||
resolved = (self.source.parent / cur).resolve()
|
||||
# only substitute if link points to an actual file
|
||||
if resolved.exists():
|
||||
item = self.item_map.get(resolved)
|
||||
if item:
|
||||
href = util.normalize_url(item.url)
|
||||
logger.debug(
|
||||
f"Link in file {self.source}: {_href} resolved to {href}"
|
||||
)
|
||||
def run(self, root: etree.Element):
|
||||
for element in root.iter("a"):
|
||||
href = element.get("href")
|
||||
if not href:
|
||||
continue
|
||||
if self.resolve:
|
||||
assert self.config
|
||||
cur = Path(href)
|
||||
_href = href
|
||||
same_file = False
|
||||
resolved = Path()
|
||||
# href starting with anchor reference the current file
|
||||
if href.startswith("#"):
|
||||
same_file = True
|
||||
elif href.startswith("/"):
|
||||
# resolve relative to content root
|
||||
resolved = (
|
||||
self.layout.content / cur.relative_to("/")
|
||||
).resolve()
|
||||
else:
|
||||
logger.debug(
|
||||
f"Warning: resolved path {resolved} not found in item map"
|
||||
)
|
||||
body: Any = self.render_children(element)
|
||||
return f'<a href="{href}" target="_blank">{body}</a>'
|
||||
# treat as relative link and try to resolve
|
||||
resolved = (self.source.parent / cur).resolve()
|
||||
# check if the link is internal
|
||||
internal = same_file
|
||||
if not same_file:
|
||||
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>"
|
||||
)
|
||||
|
||||
@override
|
||||
def render_fenced_code(self, element: FencedCode):
|
||||
assert self.config
|
||||
config = self.config.markdown.syntax_highlighting
|
||||
code = "".join(child.children for child in element.children) # type: ignore
|
||||
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 get_formatter(config: ZonaConfig):
|
||||
c = config.markdown.syntax_highlighting
|
||||
formatter = HtmlFormatter(
|
||||
style=c.theme, nowrap=not c.wrap, nobackground=True
|
||||
)
|
||||
return formatter
|
||||
|
||||
|
||||
def md_to_html(
|
||||
|
@ -117,20 +165,71 @@ def md_to_html(
|
|||
source: Path | None = None,
|
||||
layout: Layout | None = None,
|
||||
item_map: dict[Path, Item] | None = None,
|
||||
metadata: Metadata | None = None,
|
||||
) -> str:
|
||||
if resolve_links and (
|
||||
source is None or layout is None or item_map is None
|
||||
):
|
||||
raise TypeError(
|
||||
"md_to_html() missing source and ctx when resolve_links is true"
|
||||
extensions: Sequence[Any] = [
|
||||
BetterEmExtension(),
|
||||
SuperFencesCodeExtension(
|
||||
disable_indented_code_blocks=True,
|
||||
css_class="codehilite",
|
||||
),
|
||||
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"),
|
||||
]
|
||||
)
|
||||
parser = Parser()
|
||||
ast = parser.parse(content)
|
||||
renderer = ZonaRenderer(
|
||||
config,
|
||||
resolve_links,
|
||||
source,
|
||||
layout=layout,
|
||||
item_map=item_map,
|
||||
kwargs["tab_length"] = config.markdown.tab_length
|
||||
md = Markdown(**kwargs)
|
||||
if resolve_links:
|
||||
if source is None or layout is None or item_map is None:
|
||||
raise TypeError(
|
||||
"md_to_html() missing source and ctx when resolve_links is true"
|
||||
)
|
||||
md.treeprocessors.register(
|
||||
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 renderer.render(ast)
|
||||
return md.convert(content)
|
||||
|
||||
|
||||
def get_style_defs(config: ZonaConfig) -> str:
|
||||
formatter = get_formatter(config)
|
||||
defs = formatter.get_style_defs(".codehilite")
|
||||
assert isinstance(defs, str)
|
||||
return defs
|
||||
|
|
|
@ -1,29 +1,59 @@
|
|||
from dataclasses import dataclass
|
||||
from rich import print
|
||||
from datetime import date, datetime, time, tzinfo
|
||||
from pathlib import Path
|
||||
from datetime import date
|
||||
|
||||
import frontmatter
|
||||
from dacite.config import Config
|
||||
from dacite.core import from_dict
|
||||
from dacite.exceptions import DaciteError
|
||||
from dateutil import parser as date_parser
|
||||
from yaml import YAMLError
|
||||
|
||||
import zona.util
|
||||
import frontmatter
|
||||
from zona.config import ZonaConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class Metadata:
|
||||
title: str
|
||||
date: date
|
||||
description: str | None
|
||||
date: datetime
|
||||
description: str
|
||||
show_title: bool = True
|
||||
show_date: bool = True
|
||||
show_nav: bool = True
|
||||
style: str | None = "/static/style.css"
|
||||
header: bool = True
|
||||
footer: bool = True
|
||||
template: str = "page.html"
|
||||
template: str | None = None
|
||||
post: bool | None = None
|
||||
draft: bool = False
|
||||
ignore: bool = False
|
||||
math: bool = True
|
||||
|
||||
|
||||
def parse_metadata(path: Path) -> tuple[Metadata, str]:
|
||||
def ensure_timezone(dt: datetime, tz: tzinfo) -> datetime:
|
||||
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
|
||||
are applied for missing fields. If there is no metadata, a Metadata
|
||||
|
@ -39,9 +69,11 @@ def parse_metadata(path: Path) -> tuple[Metadata, str]:
|
|||
raw_meta = post.metadata or {}
|
||||
defaults = {
|
||||
"title": zona.util.filename_to_title(path),
|
||||
"date": date.fromtimestamp(path.stat().st_mtime),
|
||||
"date": datetime.fromtimestamp(path.stat().st_ctime),
|
||||
"description": config.blog.defaults.description,
|
||||
}
|
||||
meta = {**defaults, **raw_meta}
|
||||
meta["date"] = parse_date(meta.get("date"), config.feed.timezone)
|
||||
try:
|
||||
metadata = from_dict(
|
||||
data_class=Metadata,
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
from pathlib import Path
|
||||
from enum import Enum
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from zona.metadata import Metadata
|
||||
|
||||
|
@ -21,6 +23,8 @@ class Item:
|
|||
type: ItemType | None = None
|
||||
copy: bool = True
|
||||
post: bool = False
|
||||
newer: Item | None = None
|
||||
older: Item | None = None
|
||||
|
||||
|
||||
# @dataclass
|
||||
|
|
|
@ -1,26 +1,124 @@
|
|||
import signal
|
||||
import io
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from types import FrameType
|
||||
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
|
||||
import tempfile
|
||||
import threading
|
||||
from typing import override
|
||||
from watchdog.observers import Observer
|
||||
from zona.builder import ZonaBuilder
|
||||
from watchdog.events import FileSystemEventHandler, FileSystemEvent
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from types import FrameType
|
||||
from typing import override
|
||||
|
||||
from rich import print
|
||||
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from zona import util
|
||||
from zona.builder import ZonaBuilder
|
||||
from zona.log import get_logger
|
||||
from zona.websockets import WebSocketServer
|
||||
|
||||
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):
|
||||
"""SimpleHTTPRequestHandler with logs suppressed."""
|
||||
|
||||
@override
|
||||
def log_message(self, format, *args): # type: ignore
|
||||
pass
|
||||
|
||||
|
||||
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
|
||||
def handle_error(self, request, client_address): # type: ignore
|
||||
_, exc_value = sys.exc_info()[:2]
|
||||
|
@ -31,12 +129,33 @@ class ZonaServer(ThreadingHTTPServer):
|
|||
|
||||
|
||||
class ZonaReloadHandler(FileSystemEventHandler):
|
||||
def __init__(self, builder: ZonaBuilder, output: Path):
|
||||
"""FileSystemEventHandler that rebuilds the website
|
||||
and triggers the browser into refreshing over WebSocket."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
builder: ZonaBuilder,
|
||||
output: Path,
|
||||
ws_server: WebSocketServer | None,
|
||||
):
|
||||
self.builder: ZonaBuilder = builder
|
||||
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:
|
||||
path = Path(str(event.src_path)).resolve()
|
||||
# ignore if the output directory has been changed
|
||||
# to avoid infinite loop
|
||||
return (
|
||||
self.output in path.parents
|
||||
or path == self.output
|
||||
|
@ -45,56 +164,97 @@ class ZonaReloadHandler(FileSystemEventHandler):
|
|||
|
||||
@override
|
||||
def on_modified(self, event: FileSystemEvent):
|
||||
if not self._should_ignore(event):
|
||||
logger.info(f"Modified: {event.src_path}, rebuilding...")
|
||||
self.builder.build()
|
||||
self._trigger_rebuild(event)
|
||||
|
||||
@override
|
||||
def on_created(self, event: FileSystemEvent):
|
||||
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()
|
||||
self._trigger_rebuild(event)
|
||||
|
||||
|
||||
def serve(
|
||||
root: Path | None = None,
|
||||
output: Path | None = None,
|
||||
draft: bool = True,
|
||||
host: str = "localhost",
|
||||
port: int = 8000,
|
||||
user_reload: bool | None = None,
|
||||
):
|
||||
builder = ZonaBuilder(root, output)
|
||||
builder.build()
|
||||
if output is None:
|
||||
output = builder.layout.output
|
||||
if root is None:
|
||||
root = builder.layout.root
|
||||
"""Serve preview website with live reload and automatic rebuild."""
|
||||
# create temp dir, automatic cleanup
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
builder = ZonaBuilder(root, Path(tmp), draft)
|
||||
config = builder.config
|
||||
# initial site build
|
||||
builder.build()
|
||||
# use discovered paths if none provided
|
||||
if output is None:
|
||||
output = builder.layout.output
|
||||
if root is None:
|
||||
root = builder.layout.root
|
||||
|
||||
server_thread = threading.Thread(
|
||||
target=run_http_server, args=(output, host, port), daemon=True
|
||||
)
|
||||
server_thread.start()
|
||||
# use config value unless overridden by user
|
||||
reload = config.server.reload.enabled
|
||||
if user_reload is not None:
|
||||
reload = user_reload
|
||||
if reload:
|
||||
print("Live reloading is enabled.")
|
||||
# spin up websocket server for live reloading
|
||||
ws_port = port + 1
|
||||
ws_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>")
|
||||
|
||||
event_handler = ZonaReloadHandler(builder, output)
|
||||
observer = Observer()
|
||||
observer.schedule(event_handler, path=str(root), recursive=True)
|
||||
observer.start()
|
||||
# start server in a thread
|
||||
server_thread = threading.Thread(
|
||||
target=httpd.serve_forever, daemon=True
|
||||
)
|
||||
server_thread.start()
|
||||
|
||||
def shutdown_handler(_a: int, _b: FrameType | None):
|
||||
logger.info("Shutting down...")
|
||||
observer.stop()
|
||||
# initialize reload handler
|
||||
event_handler = ZonaReloadHandler(builder, output, ws_server)
|
||||
observer = Observer()
|
||||
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()
|
||||
|
||||
signal.signal(signal.SIGINT, shutdown_handler)
|
||||
signal.signal(signal.SIGTERM, shutdown_handler)
|
||||
# function to shut down gracefully
|
||||
def shutdown_handler(_a: int, _b: FrameType | None):
|
||||
print("Shutting down...")
|
||||
observer.stop()
|
||||
httpd.shutdown()
|
||||
|
||||
observer.join()
|
||||
# register shutdown handler
|
||||
signal.signal(signal.SIGINT, shutdown_handler)
|
||||
signal.signal(signal.SIGTERM, shutdown_handler)
|
||||
|
||||
# start file change watcher
|
||||
observer.join()
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from zona.models import Item
|
||||
|
||||
from zona import util
|
||||
from zona.config import ZonaConfig
|
||||
from zona.markdown import md_to_html
|
||||
from zona.models import Item
|
||||
|
||||
|
||||
def get_header(template_dir: Path) -> str | None:
|
||||
|
@ -23,30 +27,46 @@ def get_footer(template_dir: Path) -> str | None:
|
|||
return html_footer.read_text()
|
||||
|
||||
|
||||
# TODO: add next/prev post button logic to posts
|
||||
# TODO: add a recent posts element that can be included elsewhere?
|
||||
class Templater:
|
||||
def __init__(self, template_dir: Path, post_list: list[Item]):
|
||||
def __init__(
|
||||
self,
|
||||
config: ZonaConfig,
|
||||
template_dir: Path,
|
||||
post_list: list[Item],
|
||||
):
|
||||
# build temporary template dir
|
||||
self.env: Environment = Environment(
|
||||
loader=FileSystemLoader(template_dir),
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
)
|
||||
self.config: ZonaConfig = config
|
||||
self.template_dir: Path = template_dir
|
||||
self.header: str | None = get_header(template_dir)
|
||||
self.footer: str | None = get_footer(template_dir)
|
||||
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:
|
||||
env = self.env
|
||||
meta = item.metadata
|
||||
assert meta is not None
|
||||
template = env.get_template(
|
||||
meta.template
|
||||
if meta.template.endswith(".html")
|
||||
else meta.template + ".html"
|
||||
)
|
||||
if meta.template is None:
|
||||
if item.post:
|
||||
template_name = "page.html"
|
||||
else:
|
||||
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] = (
|
||||
self.header if self.header and meta.header else False
|
||||
self.render_header() if meta.header else False
|
||||
)
|
||||
footer: str | Literal[False] = (
|
||||
self.footer if self.footer and meta.footer else False
|
||||
|
@ -57,5 +77,12 @@ class Templater:
|
|||
metadata=meta,
|
||||
header=header,
|
||||
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,
|
||||
)
|
||||
|
|
106
src/zona/util.py
106
src/zona/util.py
|
@ -1,10 +1,36 @@
|
|||
from typing import NamedTuple
|
||||
from rich import print
|
||||
from importlib import resources
|
||||
import fnmatch
|
||||
import re
|
||||
import shutil
|
||||
import string
|
||||
import tempfile
|
||||
import weakref
|
||||
from importlib import resources
|
||||
from importlib.resources.abc import Traversable
|
||||
from pathlib import Path
|
||||
from shutil import copy2
|
||||
import string
|
||||
from typing import Any, NamedTuple, override
|
||||
|
||||
|
||||
class TempDir:
|
||||
"""Temporary directory that cleans up when it's garbage collected."""
|
||||
|
||||
def __init__(self):
|
||||
self._tempdir: str = tempfile.mkdtemp()
|
||||
self.path: Path = Path(self._tempdir)
|
||||
self._finalizer: weakref.finalize[Any, Any] = (
|
||||
weakref.finalize(self, shutil.rmtree, self._tempdir)
|
||||
)
|
||||
|
||||
def remove(self):
|
||||
self._finalizer()
|
||||
|
||||
@property
|
||||
def removed(self):
|
||||
return not self._finalizer.alive
|
||||
|
||||
@override
|
||||
def __repr__(self) -> str:
|
||||
return f"<TempDir {self.path}>"
|
||||
|
||||
|
||||
class ZonaResource(NamedTuple):
|
||||
|
@ -12,16 +38,36 @@ class ZonaResource(NamedTuple):
|
|||
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]:
|
||||
"""Load the packaged resources in data/subdir"""
|
||||
out: list[ZonaResource] = []
|
||||
for resource in (
|
||||
resources.files("zona").joinpath(f"data/{subdir}").iterdir()
|
||||
):
|
||||
out.append(
|
||||
ZonaResource(f"{subdir}/{resource.name}", resource.read_text())
|
||||
)
|
||||
print(out)
|
||||
base = resources.files("zona").joinpath("data", subdir)
|
||||
|
||||
def walk(trav: Traversable, prefix: str = ""):
|
||||
for item in trav.iterdir():
|
||||
path = f"{prefix}{item.name}"
|
||||
if item.is_dir():
|
||||
walk(item, prefix=f"{path}/")
|
||||
else:
|
||||
out.append(
|
||||
ZonaResource(
|
||||
name=f"{subdir}/{path}",
|
||||
contents=item.read_text(),
|
||||
)
|
||||
)
|
||||
|
||||
walk(base)
|
||||
return out
|
||||
|
||||
|
||||
|
@ -44,6 +90,15 @@ def copy_static_file(src: Path, dst: Path):
|
|||
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:
|
||||
name = path.stem
|
||||
words = name.replace("-", " ").replace("_", " ")
|
||||
|
@ -56,6 +111,31 @@ def normalize_url(url: str) -> str:
|
|||
return url
|
||||
|
||||
|
||||
def should_ignore(path: Path, patterns: list[str], base: Path) -> bool:
|
||||
def should_ignore(
|
||||
path: Path, patterns: list[str], base: Path
|
||||
) -> bool:
|
||||
rel_path = path.relative_to(base)
|
||||
return any(fnmatch.fnmatch(str(rel_path), pattern) for pattern in patterns)
|
||||
return any(
|
||||
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()
|
||||
|
|
68
src/zona/websockets.py
Normal file
68
src/zona/websockets.py
Normal file
|
@ -0,0 +1,68 @@
|
|||
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
|
||||
)
|
|
@ -1,9 +1,11 @@
|
|||
import pytest
|
||||
from datetime import date
|
||||
from zona.metadata import Metadata
|
||||
from zona.builder import split_metadata, discover, build
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from zona.builder import build, discover, split_metadata
|
||||
from zona.metadata import Metadata
|
||||
|
||||
|
||||
def test_split_metadata(tmp_path: Path):
|
||||
content = """---
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from pathlib import Path
|
||||
|
||||
from zona import util
|
||||
|
||||
|
||||
|
|
179
uv.lock
generated
179
uv.lock
generated
|
@ -44,6 +44,16 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/94/35/386550fd60316d1e37eccdda609b074113298f23cef5bddb2049823fe666/dacite-1.9.2-py3-none-any.whl", hash = "sha256:053f7c3f5128ca2e9aceb66892b1a3c8936d02c686e707bee96e19deef4bc4a0", size = 16600, upload-time = "2025-02-05T09:27:24.345Z" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "iniconfig"
|
||||
version = "2.1.0"
|
||||
|
@ -65,6 +75,77 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "markdown-it-py"
|
||||
version = "3.0.0"
|
||||
|
@ -77,15 +158,6 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.2"
|
||||
|
@ -178,14 +250,14 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pygments-ashen"
|
||||
version = "0.1.1"
|
||||
version = "0.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pygments" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -200,6 +272,19 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pytest"
|
||||
version = "8.4.1"
|
||||
|
@ -216,6 +301,18 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "python-frontmatter"
|
||||
version = "1.1.0"
|
||||
|
@ -301,6 +398,15 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "typer"
|
||||
version = "0.16.0"
|
||||
|
@ -370,21 +476,57 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "zona"
|
||||
version = "0.1.0"
|
||||
version = "1.2.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "dacite" },
|
||||
{ name = "feedgen" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "marko" },
|
||||
{ name = "l2m4m" },
|
||||
{ name = "markdown" },
|
||||
{ name = "pygments" },
|
||||
{ name = "pygments-ashen" },
|
||||
{ name = "pygments-kakoune" },
|
||||
{ name = "pymdown-extensions" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "python-frontmatter" },
|
||||
{ name = "rich" },
|
||||
{ name = "typer" },
|
||||
{ name = "watchdog" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
|
@ -398,15 +540,20 @@ dev = [
|
|||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "dacite", specifier = ">=1.9.2" },
|
||||
{ name = "feedgen", specifier = ">=1.0.0" },
|
||||
{ name = "jinja2", specifier = ">=3.1.6" },
|
||||
{ name = "marko", specifier = ">=2.1.4" },
|
||||
{ name = "l2m4m", specifier = ">=1.0.4" },
|
||||
{ name = "markdown", specifier = ">=3.8.2" },
|
||||
{ name = "pygments", specifier = ">=2.19.1" },
|
||||
{ name = "pygments-ashen", specifier = ">=0.1.0" },
|
||||
{ name = "pygments-ashen", specifier = ">=0.1.3" },
|
||||
{ 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 = "rich", specifier = ">=14.0.0" },
|
||||
{ name = "typer", specifier = ">=0.16.0" },
|
||||
{ name = "watchdog", specifier = ">=6.0.0" },
|
||||
{ name = "websockets", specifier = ">=15.0.1" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue