diff --git a/.forgejo/workflows/publish.yml b/.forgejo/workflows/publish.yml new file mode 100644 index 0000000..fde3206 --- /dev/null +++ b/.forgejo/workflows/publish.yml @@ -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 }} + diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml new file mode 100644 index 0000000..2a05ee6 --- /dev/null +++ b/.forgejo/workflows/test.yml @@ -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 diff --git a/.kakrc b/.kakrc new file mode 100644 index 0000000..688bcbe --- /dev/null +++ b/.kakrc @@ -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} +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9f6eed9 --- /dev/null +++ b/CHANGELOG.md @@ -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! diff --git a/LICENSE b/LICENSE index ac101d3..1cbda58 100644 --- a/LICENSE +++ b/LICENSE @@ -1,13 +1,24 @@ -Copyright (c) 2025 Daniel Fichtinger +Copyright (c) 2025 Daniel Fichtinger -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. diff --git a/README.md b/README.md index 673073b..6fe124a 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,233 @@ -

Zona

+

zona

-[Zona](https://sr.ht/~ficd/zona) is an opinionated static site generator written -in Python. From a structured directory of Markdown content, Zona builds a simple -static website. It's designed to get out of your way and let you focus on -writing. +[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. -**Note:** This project is in early development, and there are no versioned -releases yet. For an example of a website built with Zona, please see -[ficd.ca](https://ficd.ca) +**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. + +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). + - [Features](#features) - - [Internal Link Resolution](#internal-link-resolution) - - [Syntax Highlighting](#syntax-highlighting) - - [Image Labels](#image-labels) - [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) - - [Configuration](#configuration) + - [Post List](#post-list) +- [Configuration](#configuration) + - [Sitemap](#sitemap) + - [Ignore List](#ignore-list) + - [Drafts](#drafts) +- [Known Problems](#known-problems) + ## Features -- Live preview server with automatic rebuilding. +- 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 automatically discovered and rendered accordingly (can be + - 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 +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 @@ -49,23 +236,67 @@ 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. +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 plugins are included: +following Pygments plugins are included: -- [pygments-kakoune](https://git.sr.ht/~ficd/pygments-kakoune) +- [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://git.sr.ht/~ficd/ashen/tree/main/item/pygments/README.md) - - An implementation of the [Ashen](https://git.sr.ht/~ficd/ashen) theme for +- [pygments-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 +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: @@ -82,57 +313,181 @@ The above results in the following HTML: ``` The `image-container` class is provided as a convenience for styling. The -default stylesheet centers the label under the image. - -## Installation - -Zona is not yet packaged on PyPI. You may use `uv` to install it from this -repository: - -```sh -uv tool install 'git+https://git.sr.ht/~ficd/zona' -``` - -## Usage - -_Note: you may provide the `--help` option to any subcommand to see the -available options and arguments. - -To set up a new website, create a new directory and run `zona init` inside of -it. This creates the required directory structure and writes the default -configuration file. The default templates and default stylesheet are also -written. - -To build the website, run `zona build`. The project root is discovered according -to the location of `config.yml`. By default, the output directory is called -`public`, and saved inside the root directory. - -To start a live preview session, execute `zona serve`. The server will run until -it's killed by the user, and the website is rebuilt if any source files are -modified. - -### Site Layout - -The **root** of the **Zona project** _must_ contain the configuration file, -`config.yml`, and a directory called `content`. A directory called `templates` -is optional, and prioritized if it exists. `public` is the built site output — -it's recommended to add this path to your `.gitignore`. - -The `content` directory is the root of the website. 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.** - -Markdown files inside a certain directory (`content/blog` by default) are -automatically treated as _blog posts_. This means they are rendered with the -`page.html` template, and included in the `post_list`, which can be included in -your site using the `post_list.html` template. +default stylesheet centers the label under the image. Note: _links_ inside image +captions are not currently supported. I am looking into a solution. ### Frontmatter -WIP +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: -### Configuration +| 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. | -WIP +**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. diff --git a/justfile b/justfile index cfb1ed2..c0f2e14 100644 --- a/justfile +++ b/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 diff --git a/pyproject.toml b/pyproject.toml index ec3a6f3..185c12d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,17 +1,20 @@ [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", + "feedgen>=1.0.0", "jinja2>=3.1.6", + "l2m4m>=1.0.4", "markdown>=3.8.2", - "marko>=2.1.4", "pygments>=2.19.1", "pygments-ashen>=0.1.3", "pygments-kakoune>=0.1.0", @@ -21,8 +24,12 @@ dependencies = [ "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" @@ -51,12 +58,12 @@ reportUnusedCallResult = false reportCallInDefaultInitializer = false enableTypeIgnoreComments = true reportIgnoreCommentWithoutRule = false -allowedUntypedLibraries = ["frontmatter", "pygments", "pymdownx"] +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"] diff --git a/src/zona/builder.py b/src/zona/builder.py index ac1a5e7..dd9509f 100644 --- a/src/zona/builder.py +++ b/src/zona/builder.py @@ -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() @@ -29,6 +32,7 @@ class ZonaBuilder: 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 @@ -47,19 +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" ): - logger.debug( - f"Parsing {path.name} as Markdown document." + logger.debug(f"Parsing {path.name}.") + item.metadata, item.content = parse_metadata( + path, config=self.config ) - item.metadata, item.content = parse_metadata(path) - if ( + if item.metadata.ignore or ( item.metadata.draft and not self.config.build.include_drafts ): continue - if item.metadata.post == True: + if item.metadata.post: item.post = True elif item.metadata.post is None: # check if in posts dir? @@ -70,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 @@ -86,13 +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) 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 @@ -100,6 +150,22 @@ 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( config=self.config, template_dir=self.layout.templates, @@ -112,7 +178,9 @@ class ZonaBuilder: # 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" + 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(): @@ -129,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) @@ -139,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 diff --git a/src/zona/cli.py b/src/zona/cli.py index 8ecaaf1..da15a22 100644 --- a/src/zona/cli.py +++ b/src/zona/cli.py @@ -1,10 +1,13 @@ -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, get_logger +from zona.log import get_logger, setup_logging app = typer.Typer() logger = get_logger() @@ -57,7 +60,9 @@ def build( """ if draft: print("Option override: including drafts.") - builder = ZonaBuilder(cli_root=root, cli_output=output, draft=draft) + builder = ZonaBuilder( + cli_root=root, cli_output=output, draft=draft + ) builder.build() @@ -69,16 +74,41 @@ def serve( 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" + "--output", + "-o", + help="Location to write built website. Temporary directory by default.", ), ] = None, - draft: Annotated[ + final: Annotated[ bool, - typer.Option("--draft", "-d", help="Include drafts."), + 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. @@ -87,13 +117,41 @@ def serve( Optionally specify the ROOT and OUTPUT directories. """ - if draft: - print("Option override: including drafts.") - server.serve(root, output, draft) + 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( diff --git a/src/zona/config.py b/src/zona/config.py index b735c85..84844c7 100644 --- a/src/zona/config.py +++ b/src/zona/config.py @@ -1,7 +1,15 @@ +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() @@ -23,9 +31,17 @@ def find_config(start: Path | None = None) -> Path | 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 @@ -35,36 +51,90 @@ class HighlightingConfig: wrap: bool = False +@dataclass +class LinksConfig: + external_new_tab: bool = True + + @dataclass class MarkdownConfig: image_labels: bool = True + tab_length: int = 2 syntax_highlighting: HighlightingConfig = field( default_factory=HighlightingConfig ) + links: LinksConfig = field(default_factory=LinksConfig) @dataclass class BuildConfig: - # clean_output_dir: bool = True + clean_output_dir: bool = True 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: base_url: str = "/" + feed: FeedConfig = field(default_factory=FeedConfig) # dictionary where key is name, value is url - sitemap: SitemapConfig = field(default_factory=lambda: {"Home": "/"}) + sitemap: SitemapConfig = field( + default_factory=lambda: {"Home": "/"} + ) # list of globs relative to content that should be ignored ignore: list[str] = field(default_factory=lambda: IGNORELIST) markdown: MarkdownConfig = field(default_factory=MarkdownConfig) 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 diff --git a/src/zona/data/content/static/style.css b/src/zona/data/content/static/style.css index 675056d..caa0eef 100644 --- a/src/zona/data/content/static/style.css +++ b/src/zona/data/content/static/style.css @@ -1,20 +1,170 @@ :root { - --main-text-color: #b4b4b4; - --main-bg-color: #121212; - --main-link-color: #df6464; - --main-heading-color: #df6464; - --main-bullet-color: #d87c4a; - --main-transparent: rgba(255, 255, 255, 0.15); - --main-small-text-color: rgba(255, 255, 255, 0.45); + --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 { - 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%); + 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, */ @@ -23,225 +173,351 @@ h3, h4, h5, h6 { - color: var(--main-heading-color); + color: var(--main-heading-color); } h1 { - margin-block-start: 0.67rem; - margin-block-end: 0.67rem; - font-size: 2rem; - font-weight: bold; + 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; + margin-block-start: 1.67rem; } h2 { - margin-block-start: 0.83rem; - margin-block-end: 0.83rem; - font-size: 1.5rem; - font-weight: bold; + 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; + 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; + 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; +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; + 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; + 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: disc; - /* or any other list style */ + 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); - /* Change this to your desired color */ + color: var(--main-bullet-color); } a { - color: var(--main-link-color); + 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 { - background: var(--main-transparent); + 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; + display: block; + margin-left: auto; + margin-right: auto; + width: auto; + height: auto; } blockquote { - color: var(--main-small-text-color); - border-left: 3px solid var(--main-transparent); - padding: 0 1rem; - margin-left: 0; - margin-right: 0; + 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); + 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; */ + 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: 'Fira Code', 'Consolas', 'Courier New', monospace; - font-size: 0.95em; + 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; + 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; - border-radius: 3px; - background-color: #1d1d1d; - color: #d5d5d5; - font-size: 0.85em; + 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; + padding: 0; + border-radius: 0; + background: none; } small { - font-size: 0.95rem; - color: var(--main-small-text-color); + font-size: 0.95rem; + color: var(--main-small-text-color); } small a { - color: inherit; - /* Inherit the color of the surrounding text */ - text-decoration: underline; - /* Optional: Keep the underline to indicate a link */ + color: inherit; + /* Inherit the color of the surrounding 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; + display: flex; + justify-content: center; + align-items: center; + text-align: center; } .title-container h1 { - margin: 0; + margin: 0; } .image-container { - text-align: center; - margin: 20px 0; - /* Optional: add some spacing around the 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: 308px; */ - max-height: 308px; + 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 */ + 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 */ + 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; + list-style-type: none; + padding-left: 0; } #header li { - display: inline; - font-size: 1.2rem; - margin-right: 1.2rem; + display: inline; + font-size: 1.2rem; + margin-right: 1.2rem; } #container { - margin: 2.5rem auto; - width: 90%; - max-width: 60ch; + margin: 2.5rem auto; + width: 90%; + max-width: 60ch; } #postlistdiv ul { - list-style-type: none; - padding-left: 0; + list-style-type: none; + padding-left: 0; } .moreposts { - font-size: 0.95rem; - padding-left: 0.5rem; + font-size: 0.95rem; + padding-left: 0.5rem; } #nextprev { - text-align: center; - margin-top: 1.4rem; - font-size: 0.95rem; + text-align: center; + margin-top: 1.4rem; + font-size: 0.95rem; } #footer { - color: var(--main-small-text-color); + 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); +} diff --git a/src/zona/data/server/inject.js b/src/zona/data/server/inject.js new file mode 100644 index 0000000..bc00dde --- /dev/null +++ b/src/zona/data/server/inject.js @@ -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(); + } + }; +})(); diff --git a/src/zona/data/templates/basic.html b/src/zona/data/templates/basic.html index fbedadb..d86a25a 100644 --- a/src/zona/data/templates/basic.html +++ b/src/zona/data/templates/basic.html @@ -1,6 +1,10 @@ {% extends "base.html" %} {% block content %} +{% if metadata.show_title %} +{% include "title.html" %} +{% endif %} {{ content | safe }} {% endblock %} + diff --git a/src/zona/data/templates/page.html b/src/zona/data/templates/page.html index 91b2616..0ff499b 100644 --- a/src/zona/data/templates/page.html +++ b/src/zona/data/templates/page.html @@ -1,10 +1,12 @@ -{% extends "base.html" %} +{% extends "base.html" %} {% block content %} {% if metadata.show_title %} {% +include "title.html" %} {% if metadata.date.date() %} +
+ +
+{% endif %} {% endif %} {% if is_post %} {% include "post_nav.html" %} {% endif +%} +
-{% block content %} -

{{ metadata.title }}

-{% if metadata.date %} -
-{% endif %}
{{ content | safe }}
{% endblock %} - diff --git a/src/zona/data/templates/post_list.html b/src/zona/data/templates/post_list.html index 8545020..30ea74f 100644 --- a/src/zona/data/templates/post_list.html +++ b/src/zona/data/templates/post_list.html @@ -1,17 +1,18 @@ -{% extends "base.html" %} - -{% block content %} - -

{{ metadata.title }}

+{% extends "base.html" %} {% block content %} {% if metadata.show_title %} {% +include "title.html" %} {% endif %}
{{ content | safe }}
{% if post_list %} +
-{% endif %} -{% endblock %} - +
+{% endif %} {% endblock %} diff --git a/src/zona/data/templates/post_nav.html b/src/zona/data/templates/post_nav.html new file mode 100644 index 0000000..1e96ff5 --- /dev/null +++ b/src/zona/data/templates/post_nav.html @@ -0,0 +1,11 @@ +
+
+ <{% if newer %}newr{% + else %}null{% endif %}{% if older %}oldr{% else %}null{% endif %}> +
+
diff --git a/src/zona/data/templates/title.html b/src/zona/data/templates/title.html new file mode 100644 index 0000000..1541095 --- /dev/null +++ b/src/zona/data/templates/title.html @@ -0,0 +1,2 @@ +

{{ metadata.title }}

+ diff --git a/src/zona/layout.py b/src/zona/layout.py index 688a902..f4c7311 100644 --- a/src/zona/layout.py +++ b/src/zona/layout.py @@ -1,10 +1,13 @@ +from dataclasses import asdict, dataclass from pathlib import Path +from zoneinfo import ZoneInfo + import typer -from dataclasses import dataclass, asdict -from zona.config import ZonaConfig, find_config -from zona import util, log import yaml +from zona import log, util +from zona.config import ZonaConfig, find_config + logger = log.get_logger() @@ -14,16 +17,39 @@ 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...") @@ -32,10 +58,29 @@ class Layout: raise FileNotFoundError( "Missing required content directory!" ) - if not layout.templates.is_dir(): + internal_templates = util.get_resource_dir("templates") + user_templates = layout.templates + if not user_templates.is_dir() or util.is_empty( + user_templates + ): logger.debug("Using default template directory.") # 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 @@ -80,7 +125,8 @@ def initialize_site(root: Path | None = None): layout = Layout.from_input(root=root, validate=False) # load template resources logger.debug("Loading internal templates.") - templates = util.get_resources("templates") + # only write the footer + templates = [util.get_resource("templates/footer.md")] logger.debug("Loading internal static content.") static = util.get_resources("content") for dir, resources in [ @@ -101,9 +147,13 @@ def initialize_site(root: Path | None = None): 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, diff --git a/src/zona/log.py b/src/zona/log.py index 129a36c..545ec49 100644 --- a/src/zona/log.py +++ b/src/zona/log.py @@ -1,5 +1,5 @@ import logging -from typing import Literal + from rich.logging import RichHandler _LOGGER_NAME = "zona" diff --git a/src/zona/markdown.py b/src/zona/markdown.py index 1a72804..1132f22 100644 --- a/src/zona/markdown.py +++ b/src/zona/markdown.py @@ -1,28 +1,37 @@ -from collections.abc import Sequence -from rich import print -from markdown import Markdown -from typing import Any, override -from pathlib import Path - -from pygments.formatters.html import HtmlFormatter -from zona.config import ZonaConfig -from zona.layout import Layout - -from markdown.treeprocessors import Treeprocessor -from markdown.extensions.codehilite import CodeHiliteExtension -from markdown.extensions.extra import ExtraExtension -from markdown.extensions.smarty import SmartyExtension -from markdown.extensions.sane_lists import SaneListExtension -from markdown.extensions.md_in_html import MarkdownInHtmlExtension -from pymdownx.inlinehilite import InlineHiliteExtension -from pymdownx.escapeall import EscapeAllExtension import xml.etree.ElementTree as etree +from collections.abc import Sequence +from logging import Logger +from pathlib import Path +from typing import Any, override + +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 ZonaImageTreeprocessor(Treeprocessor): @@ -31,6 +40,7 @@ class ZonaImageTreeprocessor(Treeprocessor): def __init__(self, md: Markdown): super().__init__() self.md: Markdown = md + self.logger: Logger = get_logger() @override def run(self, root: etree.Element): @@ -75,6 +85,7 @@ class ZonaLinkTreeprocessor(Treeprocessor): ): super().__init__() self.resolve: bool = resolve + self.logger: Logger = get_logger() if self.resolve: assert source is not None assert layout is not None @@ -91,9 +102,15 @@ class ZonaLinkTreeprocessor(Treeprocessor): if not href: continue if self.resolve: + assert self.config cur = Path(href) _href = href - if href.startswith("/"): + 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("/") @@ -101,20 +118,36 @@ class ZonaLinkTreeprocessor(Treeprocessor): else: # 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 - if resolved.exists(): + # 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) - element.set("href", href) - logger.debug( - f"Link in file {self.source}: {_href} resolved to {href}" - ) + # 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: - logger.debug( + self.logger.debug( f"Warning: resolved path {resolved} not found in item map" ) - element.set("target", "_blank") + # 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") def get_formatter(config: ZonaConfig): @@ -132,27 +165,49 @@ def md_to_html( source: Path | None = None, layout: Layout | None = None, item_map: dict[Path, Item] | None = None, + metadata: Metadata | None = None, ) -> str: extensions: Sequence[Any] = [ - ExtraExtension(), + BetterEmExtension(), + SuperFencesCodeExtension( + disable_indented_code_blocks=True, + css_class="codehilite", + ), + FootnoteExtension(), + AttrListExtension(), + DefListExtension(), + TocExtension( + anchorlink=True, + ), + TableExtension(), + AbbrExtension(), SmartyExtension(), - "pymdownx.tilde", - "pymdownx.caret", - "pymdownx.smartsymbols", - InlineHiliteExtension(css_class="codehilite"), + 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: - extensions.append( - CodeHiliteExtension( - linenums=False, - noclasses=False, - pygments_style=config.markdown.syntax_highlighting.theme, - ) + kwargs["extensions"].extend( + [ + CodeHiliteExtension( + linenums=False, + noclasses=False, + pygments_style=config.markdown.syntax_highlighting.theme, + ), + InlineHiliteExtension(css_class="codehilite"), + ] ) - md = Markdown(extensions=extensions) + 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( diff --git a/src/zona/metadata.py b/src/zona/metadata.py index eaa6315..4551117 100644 --- a/src/zona/metadata.py +++ b/src/zona/metadata.py @@ -1,37 +1,59 @@ from dataclasses import dataclass +from datetime import date, datetime, time, tzinfo from pathlib import Path -from datetime import date -from dateutil import parser as date_parser +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 | None = None post: bool | None = None draft: bool = False + ignore: bool = False + math: bool = True -def parse_date(raw_date: str | date | object) -> date: - if isinstance(raw_date, date): - return raw_date +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) - return date_parser.parse(raw_date).date() + dt = date_parser.parse(raw_date) + return ensure_timezone(dt, tz) -def parse_metadata(path: Path) -> tuple[Metadata, str]: +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 @@ -47,10 +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_ctime), + "date": datetime.fromtimestamp(path.stat().st_ctime), + "description": config.blog.defaults.description, } meta = {**defaults, **raw_meta} - meta["date"] = parse_date(meta.get("date")) + meta["date"] = parse_date(meta.get("date"), config.feed.timezone) try: metadata = from_dict( data_class=Metadata, diff --git a/src/zona/models.py b/src/zona/models.py index 785f02f..7f67015 100644 --- a/src/zona/models.py +++ b/src/zona/models.py @@ -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 diff --git a/src/zona/server.py b/src/zona/server.py index e2b266e..f8b0000 100644 --- a/src/zona/server.py +++ b/src/zona/server.py @@ -1,27 +1,124 @@ -import signal +import io import os +import signal import sys -from rich import print -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"" + + +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 . + """ + + 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"" in content: + content = content.replace( + "", self.script + "" + ) + else: + # if no , 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] @@ -32,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 @@ -46,57 +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 ") - httpd.serve_forever() + self._trigger_rebuild(event) def serve( root: Path | None = None, output: Path | None = None, - draft: bool = False, + draft: bool = True, host: str = "localhost", port: int = 8000, + user_reload: bool | None = None, ): - builder = ZonaBuilder(root, output, draft) - 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 ") - 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() diff --git a/src/zona/templates.py b/src/zona/templates.py index ea7df47..4529143 100644 --- a/src/zona/templates.py +++ b/src/zona/templates.py @@ -1,9 +1,12 @@ from pathlib import Path from typing import Literal + from jinja2 import Environment, FileSystemLoader, select_autoescape + +from zona import util from zona.config import ZonaConfig -from zona.models import Item from zona.markdown import md_to_html +from zona.models import Item def get_header(template_dir: Path) -> str | None: @@ -24,7 +27,6 @@ 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__( @@ -33,6 +35,7 @@ class Templater: template_dir: Path, post_list: list[Item], ): + # build temporary template dir self.env: Environment = Environment( loader=FileSystemLoader(template_dir), autoescape=select_autoescape(["html", "xml"]), @@ -74,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, ) diff --git a/src/zona/util.py b/src/zona/util.py index c3caaed..5bf6f53 100644 --- a/src/zona/util.py +++ b/src/zona/util.py @@ -1,11 +1,36 @@ -from importlib.resources.abc import Traversable -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"" class ZonaResource(NamedTuple): @@ -13,6 +38,17 @@ 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] = [] @@ -26,7 +62,8 @@ def get_resources(subdir: str) -> list[ZonaResource]: else: out.append( ZonaResource( - name=f"{subdir}/{path}", contents=item.read_text() + name=f"{subdir}/{path}", + contents=item.read_text(), ) ) @@ -53,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("_", " ") @@ -65,8 +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 + 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() diff --git a/src/zona/websockets.py b/src/zona/websockets.py new file mode 100644 index 0000000..2889af2 --- /dev/null +++ b/src/zona/websockets.py @@ -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 + ) diff --git a/tests/test_builder.py b/tests/test_builder.py index 61e148e..bd0c9b7 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -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 = """--- diff --git a/tests/test_util.py b/tests/test_util.py index 2837df4..2c1a361 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,4 +1,5 @@ from pathlib import Path + from zona import util diff --git a/uv.lock b/uv.lock index 3a0a146..c81743b 100644 --- a/uv.lock +++ b/uv.lock @@ -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,68 @@ 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" @@ -86,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" @@ -413,15 +476,47 @@ 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 = "l2m4m" }, { name = "markdown" }, - { name = "marko" }, { name = "pygments" }, { name = "pygments-ashen" }, { name = "pygments-kakoune" }, @@ -431,6 +526,7 @@ dependencies = [ { name = "rich" }, { name = "typer" }, { name = "watchdog" }, + { name = "websockets" }, ] [package.dev-dependencies] @@ -444,9 +540,10 @@ dev = [ [package.metadata] requires-dist = [ { name = "dacite", specifier = ">=1.9.2" }, + { name = "feedgen", specifier = ">=1.0.0" }, { name = "jinja2", specifier = ">=3.1.6" }, + { name = "l2m4m", specifier = ">=1.0.4" }, { name = "markdown", specifier = ">=3.8.2" }, - { name = "marko", specifier = ">=2.1.4" }, { name = "pygments", specifier = ">=2.19.1" }, { name = "pygments-ashen", specifier = ">=0.1.3" }, { name = "pygments-kakoune", specifier = ">=0.1.0" }, @@ -456,6 +553,7 @@ requires-dist = [ { 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]