diff --git a/.forgejo/workflows/publish.yml b/.forgejo/workflows/publish.yml deleted file mode 100644 index fde3206..0000000 --- a/.forgejo/workflows/publish.yml +++ /dev/null @@ -1,20 +0,0 @@ -on: - push: - tags: - - 'v*' -jobs: - publish: - runs-on: based-alpine - steps: - - uses: actions/checkout@v4 - - name: setup cache - id: uv-cache - uses: https://git.ficd.sh/ficd/uv-cache@v1 - - name: build - run: | - uv sync - uv build - - name: publish - run: | - uv publish --token ${{ secrets.PYPI_TOKEN }} - diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml deleted file mode 100644 index 2a05ee6..0000000 --- a/.forgejo/workflows/test.yml +++ /dev/null @@ -1,18 +0,0 @@ -# this workflow checks if the project can be built successfully. -# it also serves to test whether based-alpine and uv-cache are working properly. -# Unit tests will be added here eventually -on: [push] -jobs: - test-build: - runs-on: based-alpine - steps: - - name: checkout source - uses: actions/checkout@v4 - - name: setup cache - id: uv-cache - uses: https://git.ficd.sh/ficd/uv-cache@v1 - - name: sync and build - run: | - uv sync - uv build - uv run zona --version diff --git a/.kakrc b/.kakrc deleted file mode 100644 index 688bcbe..0000000 --- a/.kakrc +++ /dev/null @@ -1,25 +0,0 @@ -# commands to edit important files in the root -declare-option str project_root %sh{ git rev-parse --show-toplevel } - -define-command -params 1 root-edit %{ - edit %exp{%opt{project_root}/%arg{1}} -} - -define-command just %{ - root-edit justfile -} -define-command pyproject %{ - root-edit pyproject.toml -} -define-command readme %{ - root-edit README.md -} - -define-command kakrc %{ - root-edit .kakrc -} - -# change working directory to the package -hook global -once BufCreate .* %{ - change-directory %exp{%opt{project_root}/src/zona} -} diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 9f6eed9..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,33 +0,0 @@ -# 1.3.0 - -- Added RSS feed generation. -- Added default post description to configuration. -- Added time-of-day support to post `date` frontmatter parsing. -- `zona init` now only writes `footer.md` to the templates directory. - -# 1.2.1 - -- Added `--version` flag to CLI. - -# 1.2.0 - -- Improved the appearance and semantics of post navigation buttons. - - Navigation now follows "newer/older" logic. -- Added hover symbols to page titles. -- Improved the styling of hover symbols and links. - -# 1.1.0 - -- Major improvements to default stylesheet. -- Frontmatter option to ignore file. -- Improvements to title and date rendering in templates. -- Added smooth scrolling to default stylesheet. -- Fixed a crash when user templates directory was missing when starting the - server. -- Added "next/previous" navigation buttons to posts. -- User template directory is now merged with defaults instead of it being one or - the other. - -# 1.0.0 - -Initial release! diff --git a/LICENSE b/LICENSE index 1cbda58..ac101d3 100644 --- a/LICENSE +++ b/LICENSE @@ -1,24 +1,13 @@ -Copyright (c) 2025 Daniel Fichtinger +Copyright (c) 2025 Daniel Fichtinger -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 +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. -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. +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. diff --git a/README.md b/README.md index 6fe124a..2c4ec0f 100644 --- a/README.md +++ b/README.md @@ -1,493 +1,12 @@ -

zona

+# Zona -[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. +This repository contains a Python rewrite of +[zona](https://git.sr.ht/~ficd/zona). The project was increasing in complexity +and in need of a refactor. I decided that I would rather implement the features +in Python. -**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. +Once the rewrite is complete, this repository will be renamed to `zona`, and +`zona` will become `zona-go` and archived. -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) -- [Installation](#installation) -- [Usage](#usage) - - [Getting Started](#getting-started) - - [Building](#building) - - [Live Preview](#live-preview) - - [Live Reload](#live-reload) - - [How It Works](#how-it-works) - - [Site Layout](#site-layout) - - [Templates](#templates) - - [Markdown Footer](#markdown-footer) - - [RSS Feed Generation](#rss-feed-generation) - - [Internal Link Resolution](#internal-link-resolution) - - [Syntax Highlighting](#syntax-highlighting) - - [Markdown Extensions](#markdown-extensions) - - [Image Labels](#image-labels) - - [Frontmatter](#frontmatter) - - [Post List](#post-list) -- [Configuration](#configuration) - - [Sitemap](#sitemap) - - [Ignore List](#ignore-list) - - [Drafts](#drafts) -- [Known Problems](#known-problems) - - - -## Features - -- Live preview server: - - Automatic rebuild of site on file changes. - - Live refresh in browser preview. -- `jinja2` template support with sensible defaults included. - - Basic page, blog post, post list. -- RSS feed generation. -- Glob ignore. -- YAML frontmatter. -- Easily configurable sitemap header. -- Site footer written in Markdown. -- Smart site layout discovery. - - Blog posts are automatically discovered and rendered accordingly (can be - overridden in frontmatter). -- Extended Markdown renderer: - - Smart internal link resolution. - - Syntax highlighting. - - Includes Kakoune syntax and [Ashen] highlighting. - - [Image labels](#image-labels). - - Many `python-markdown` extensions enabled, including footnotes, tables, - abbreviations, etc. - - LaTeX support. - -## Installation - -Zona can be installed as a Python package. Instructions for -[`uv`](https://docs.astral.sh/uv/) are provided. - -```sh -# install latest release -uv tool install zona -# install bleeding edge from git -uv tool install 'git+https://git.ficd.sh/ficd/zona' -# you can also run without installation -uvx zona build --help -``` - -## Usage - -_Note: you may provide the `--help` option to any subcommand to see the -available options and arguments._ - -### Getting Started - -To set up a new website, create a new directory and run `zona init` inside of -it. This creates the required directory structure and writes the default -configuration file. The default templates and default stylesheet are also -written. - -### Building - -To build the website, run `zona build`. The project root is discovered according -to the location of `config.yml`. By default, the output directory is called -`public`, and saved inside the root directory. - -If you don't want discovery, you can specify the project root as the first -argument to `zona build`. You may specify a path for the output using the -`--output/-o` flag. The `--draft/-d` flag includes draft posts in the output. - -### Live Preview - -To make the writing process as frictionless as possible, zona ships with a live -preview server. It spins up an HTTP server, meaning that internal links work -properly (this is not the case if you simply open the `.html` files in your -browser.) - -Additionally, the server watches for changes to all source files, and rebuilds -the website when they're modified. _Note: the entire website is rebuilt — this -ensures that links are properly resolved._ - -Drafts are enabled by default in live preview. Use `--final/-f` to disable them. -By default, the build outputs to a temporary directory. Use `-o/--output` to -override this. - -**Note**: if the live preview isn't working as expected, try restarting the -server. If you change the configuration, the server must also be restarted. The -live preview uses the same function as `zona build` internally; this means that -the output is also written to disk --- a temporary directory by default, unless -overridden with `-o/--output`. - -#### Live Reload - -Optionally, live reloading of the browser is also provided. With this feature -(enabled by default), your browser will automatically refresh open pages -whenever the site is rebuilt. The live reloading requires JavaScript support -from the browser — this is why the feature is optional. - -To start a preview server, use `zona serve`. You can specify the root directory -as its first argument. Use the `--host` to specify a host name (`localhost` by -default) and `--port/-p` to specify a port (default: `8000`). - -The `--live-reload/--no-live-reload` option overrides the value set in the -[config](#configuration) (`true` by default). _Automatic site rebuilds are not -affected_. - -If you are scrolled to the bottom of the page in the browser, and you extend the -height of the page by adding new content, you will automatically be scrolled to -the _new_ bottom after reloading. You may tune the tolerance threshold in the -[configuration](#configuration). - -#### How It Works - -The basic idea is this: after a rebuild, the server needs to notify your browser -to refresh the open pages. We implement this using a small amount of JavaScript. -The server injects a tiny script into any HTML page it serves; which causes your -browser to open a WebSocket connection with the server. When the site is -rebuilt, the server notifies your browser via the WebSocket, which reloads the -page. - -Unfortunately, there is no way to implement this feature without using -JavaScript. **JavaScript is _only_ used for the live preview feature. The script -is injected by the server, and never written to the HTML files in the output -directory.** - -### Site Layout - -The following demonstrates a simple zona project layout: - -``` -config.yml -content/ -templates/ -public/ -``` - -The **root** of the zona **project** _must_ contain the configuration file, -`config.yml`, and a directory called `content`. A directory called `templates` -is optional, and merged with the defaults if it exists. `public` is the built -site output — it's recommended to add this path to your `.gitignore`. - -The `content` directory is the **root of the website**. Think of it as the -**content root**. For example, suppose your website is hosted at `example.com`. -`content/blog/index.md` corresponds to `example.com/blog`, -`content/blog/my-post.md` becomes `example.com/blog/my-post`, etc. - -- Internal links are resolved **relative to the `content` directory.** -- Templates are resolved relative to the `template` directory. - -Markdown files inside a certain directory (`content/blog` by default) are -automatically treated as _blog posts_. This means they are rendered with the -`page` template, and included in the `post_list`, which can be included in your -site using the `post_list` template. - -### Templates - -The `templates` directory may contain any `jinja2` template files. You may -modify the existing templates or create your own. Your templates are merged with -the packaged defaults. To apply a certain template to a page, set the `template` -option in its [frontmatter](#frontmatter). The following public variables are -made available to the template engine: - -| Name | Description | -| ----------- | -------------------------------------------------------- | -| `content` | The content of this page. | -| `url` | The resolved URL of this page. | -| `metadata` | The frontmatter of this page (_merged with defaults_). | -| `header` | The sitemap header in HTML form. Can be `False`. | -| `footer` | The footer in HTML form. Can be `False`. | -| `is_post` | Whether this page is a post. | -| `newer` | URL of the newer post in the post list. | -| `older` | URL of the older post in the post list. | -| `post_list` | A sorted list of `Item` objects. Meant for internal use. | - -#### Markdown Footer - -The `templates` directory can contain a file called `footer.md`. If it exists, -it's parsed and rendered into HTML, then made available to other templates as -the `footer` variable. If `footer.md` is missing but `footer.html` exists, then -it's used instead. **Note: links are _not_ resolved in the footer.** - -### RSS Feed Generation - -Zona can also generates an RSS feed containing your blog posts. This feature is -disabled by default, and you can enable it in the -[configuration](#configuration). - -The default location is a file called `rss.xml` in the content root. All RSS -related configuration is specified in `config.yml`. If you plan to use the feed, -make sure to replace the placeholder `link`, `title`, `description`, and -`author` configuration values before you set `enabled: true`. - -### Internal Link Resolution - -When zona encounters links in Markdown documents, it attempts to resolve them as -internal links. Links beginning with `/` are resolved relative to the content -root; otherwise, they are resolved relative to the Markdown file. If the link -resolves to an existing file that is part of the website, it's replaced with an -appropriate web-server-friendly link. Otherwise, the link isn't changed. - -For example, suppose the file `blog/post1.md` has a link `./post2.md`. The HTML -output will contain the link `/blog/post2` (which corresponds to -`/blog/post2/index.html`). Link resolution is applied to _all_ internal links, -including those pointing to static resources like images. Links are only -modified if they point to a real file that's not included in the ignore list. - -### Syntax Highlighting - -Zona uses [Pygments] to provide syntax highlighting for fenced code blocks. The -following Pygments plugins are included: - -- [pygments-kakoune](https://codeberg.org/ficd/pygments-kakoune) - - A lexer providing for highlighting Kakoune code. Available under the `kak` - and `kakrc` aliases. -- [pygments-ashen](https://codeberg.org/ficd/ashen/tree/main/item/pygments/README.md) - - An implementation of the [Ashen](https://codeberg.org/ficd/ashen) theme for - Pygments. - -If you want to use any external Pygments styles or lexers, they must be -available in zona's Python environment. For example, you can give zona access to -[Catppucin](https://github.com/catppuccin/python): - -```yaml -# config.yml -markdown: - syntax_highlighting: - theme: catppucin-mocha -``` - -Then, run zona with the following `uv` command: - -```sh -uvx --with catppucin zona build -``` - -Inline syntax highlighting is also provided via a `python-markdown` extension. -If you prefix inline code with a shebang followed by the language identifier, it -will be highlighted. For example: - -``` -`#!python print(f"I love {foobar}!", end="")` -will be rendered as -`print(f"I love {foobar}!", end="")` -(the #!lang is stripped) -``` - -### Markdown Extensions - -- [BetterEm](https://facelessuser.github.io/pymdown-extensions/extensions/betterem/) -- [SuperFences](https://facelessuser.github.io/pymdown-extensions/extensions/superfences/) - - `disable_indented_code_blocks=True` -- [Extra](https://python-markdown.github.io/extensions/extra/) - - Excluding Fenced Code Blocks. -- [Caret](https://facelessuser.github.io/pymdown-extensions/extensions/caret/) -- [Tilde](https://facelessuser.github.io/pymdown-extensions/extensions/tilde/) -- [Sane Lists](https://python-markdown.github.io/extensions/sane_lists/) -- [EscapeAll](https://facelessuser.github.io/pymdown-extensions/extensions/escapeall/) - - `hardbreak=True` -- [LaTeX2MathML4Markdown](https://gitlab.com/parcifal/l2m4m/-/tree/develop?ref_type=heads) - - Disable per-file with the `math: false` frontmatter option. - -### Image Labels - -A feature unique to zona is **image labels**. They make it easy to annotate -images in your Markdown documents. The alt text Markdown element is rendered as -the label — with support for inline Markdown. Consider this example: - -```markdown -![This **image** has _markup_.](static/markdown.png) -``` - -The above results in the following HTML: - -```html -
This image has -markup.
-``` - -The `image-container` class is provided as a convenience for styling. The -default stylesheet centers the label under the image. Note: _links_ inside image -captions are not currently supported. I am looking into a solution. - -### Frontmatter - -YAML frontmatter can be used to configure the metadata of documents. All of them -are optional. `none` is used when the option is unset. The following options are -available: - -| Key | Type & Default | Description | -| ------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------ | -| `title` | `str` = title-cased filename. | Title of the page. | -| `description` | `str \| none` = `none` | Description. If omitted, default from [config](#configuration) will be used. | -| `date` | Date string = file modified time. | Displayed on blog posts and used for post_list sorting. | -| `show_title` | `bool` = `true` | Whether `metadata.title` should be included in the template. | -| `header` | `bool` = `true` | Whether the header sitemap should be rendered. | -| `footer` | `bool` = `true` | Whether the footer should be rendered. | -| `template` | `str \| none` = `none` | Template to use for this page. Relative to `templates/`, `.html` extension optional. | -| `post` | `bool \| none` = `none` | Whether this page is a **post**. `true`/`false` is _absolute_. Leave it unset for automatic detection. | -| `draft` | `bool` = `false` | Whether this page is a draft. See [drafts](#drafts) for more. | -| `ignore` | `bool` = `false` | Whether this page should be ignored in _both_ `final` and `draft` contexts. | -| `math` | `bool` = `true` | Whether the LaTeX extension should be enabled for this page. | - -**Note**: you can specify the date in any format that can be parsed by -[`python-dateutil`](https://pypi.org/project/python-dateutil/). - -### Post List - -Suppose you want `example.com/blog` to be a _post list_ page, and you want -`example.com/blog/my-post` to be a post. You would first create -`content/blog/index.md` and add the following frontmatter: - -```markdown ---- -title: Blog -post: false -template: post_list ---- - -Welcome to my blog! Please find a list of my posts below. -``` - -Setting `post: false` is necessary because, by default, all documents inside -`content/blog` are considered to be posts unless explicitly disabled in the -frontmatter. We don't want the post list to list _itself_ as a post. - -Then, you'd create `content/blog/my-post.md` and populate it: - -```markdown ---- -title: My First Post -date: July 5, 2025 ---- -``` - -Because `my-post` is inside the `blog` directory, `post: true` is implied. If -you wanted to put it somewhere outside `blog`, you would need to set -`post: true` for it to be included in the post list. - -## Configuration - -Zona is configured in YAML format. The configuration file is called `config.yml` -and it **must** be located in the root of the project — in the same directory as -`content` and `templates`. - -Your configuration will be merged with the defaults. `zona init` also writes a -copy of the default configuration to the correct location. If it exists, you'll -be prompted before overwriting it. - -**Note:** Currently, not every configuration value is actually used. Only the -useful settings are listed here. - -Please see the default configuration: - -```yaml -feed: - enabled: true - timezone: UTC - path: rss.xml - link: https://example.com - title: Zona Website - description: My zona website. - language: en - author: - name: John Doe - email: john@doe.net -sitemap: - Home: / -ignore: - - .marksman.toml -markdown: - image_labels: true - tab_length: 2 - syntax_highlighting: - enabled: true - theme: ashen - wrap: false - links: - external_new_tab: true -build: - clean_output_dir: true - include_drafts: false -blog: - dir: blog - defaults: - description: A blog post -server: - reload: - enabled: true - scroll_tolerance: 100 -``` - -| Name | Description | -| -------------------------------------- | ----------------------------------------------------------------------------------------------- | -| `feed.enabled` | Whether RSS feed should be generated. **Off by default**. | -| `feed.timezime` | Timezone to use for post `pubDate` values. Must be an IANA compliant string. | -| `feed.path` | Location of the feed, relative to content root. | -| `feed.link` | The base URL of the website. | -| `feed.title` | Website title. | -| `feed.description` | Website description. | -| `feed.language` | String specifying website's language code. | -| `author.name` | Your full name. | -| `author.email` | Your email address. | -| `sitemap` | Sitemap dictionary. See [Sitemap](#sitemap). | -| `ignore` | List of paths to ignore. See [Ignore List](#ignore-list). | -| `markdown.tab_length` | How many spaces should be considered an indentation level. | -| `markdown.syntax_highlighting.enabled` | Whether code should be highlighted. | -| `markdown.syntax_highlighting.theme` | [Pygments] style for highlighting. | -| `markdown.syntax_highlighting.wrap` | Whether the resulting code block should be word wrapped. | -| `markdown.links.external_new_tab` | Whether external links should be opened in a new tab. | -| `build.clean_output_dir` | Whether previous build artifacts should be cleared when building. Recommended to leave this on. | -| `build.include_drafts` | Whether drafts should be included by default. | -| `blog.dir` | Name of a directory relative to `content/` whose children are automatically considered posts. | -| `blog.defaults.description` | Default description for blog posts with no `description` in their frontmatter. | -| `server.reload.enabled` | Whether the preview server should use [live reload](#live-preview). | -| `server.reload.scroll_tolerance` | The distance, in pixels, from the bottom to still count as "scrolled to bottom". | - -### Sitemap - -You can define a sitemap in the configuration file. This is a list of links that -will be rendered at the top of every page. The `sitemap` is a dictionary of -`string` to `string` pairs, where each key is the displayed text of the link, -and the value if the `href`. Consider this example: - -```yaml -sitemap: - Home: / - About: /about - Blog: /blog - Git: https://git.ficd.sh/ficd -``` - -### Ignore List - -You can set a list of glob patterns in the [configuration](#configuration) that -should be ignored by zona. This is useful because zona makes a copy of _every_ -file it encounters inside the `content` directory, regardless of its type. The -paths must be relative to the `content` directory. - -### Drafts - -zona allows you to begin writing content without including it in the final build -output. If you set `draft: true` in a page's frontmatter, it will be marked as a -draft. Drafts are completely excluded from `zona build` and `zona serve` unless -the `--draft` flag is specified. - -[Ashen]: https://codeberg.org/ficd/ashen -[Pygments]: https://pygments.org/ - -## Known Problems - -1. If the user triggers rebuilds in quick succession, the browser is sent the - reload command after the first build, even though a second build may be - underway. This results in a `404` page being served, and the user needs to - manually refresh the browser page. - - **Mitigation:** Don't allow a rebuild until the browser has re-connected to - the WebSocket after the first reload. +See the [tracker](https://todo.sr.ht/~ficd/zona) for updates on the status of +the rewrite. diff --git a/justfile b/justfile index c0f2e14..cfb1ed2 100644 --- a/justfile +++ b/justfile @@ -2,28 +2,6 @@ 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 185c12d..8b9c7bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,35 +1,25 @@ [project] name = "zona" -version = "1.2.2" -description = "Opinionated static site generator." -license = "BSD-3-Clause " -license-files = ["LICENSE"] +version = "0.1.0" +description = "Static site generator" readme = "README.md" authors = [ - { name = "Daniel Fichtinger", email = "daniel@ficd.sh" }, + { name = "Daniel Fichtinger", email = "daniel@ficd.ca" } ] 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", - "pygments>=2.19.1", - "pygments-ashen>=0.1.3", - "pygments-kakoune>=0.1.0", - "pymdown-extensions>=10.16", - "python-dateutil>=2.9.0.post0", - "python-frontmatter>=1.1.0", - "rich>=14.0.0", - "typer>=0.16.0", - "watchdog>=6.0.0", - "websockets>=15.0.1", + "dacite>=1.9.2", + "jinja2>=3.1.6", + "marko>=2.1.4", + "pygments>=2.19.1", + "pygments-ashen>=0.1.0", + "pygments-kakoune>=0.1.0", + "python-frontmatter>=1.1.0", + "rich>=14.0.0", + "typer>=0.16.0", + "watchdog>=6.0.0", ] -[project.urls] -Repository = "https://git.ficd.sh/ficd/zona" - [project.scripts] zona = "zona.cli:main" @@ -48,7 +38,9 @@ exclude = [ ] executionEnvironments = [ { root = "src" }, - { root = "tests", extraPaths = ["src"], reportPrivateUsage = false }, + { root = "tests", extraPaths = [ + "src", + ], reportPrivateUsage = false }, ] # off | basic | standard | strict | recommended | all typeCheckingMode = "recommended" @@ -58,12 +50,12 @@ reportUnusedCallResult = false reportCallInDefaultInitializer = false enableTypeIgnoreComments = true reportIgnoreCommentWithoutRule = false -allowedUntypedLibraries = ["frontmatter", "pygments", "pymdownx", "l2m4m", "feedgen", "feedgen.feed"] +allowedUntypedLibraries = ["frontmatter", "pygments"] [tool.ruff] line-length = 70 indent-width = 4 -target-version = "py312" +target-version = "py311" [tool.ruff.lint] fixable = ["ALL"] @@ -76,6 +68,7 @@ quote-style = "double" indent-style = "space" docstring-code-format = true + [tool.pytest.ini_options] pythonpath = "src" testpaths = ["tests"] @@ -87,8 +80,8 @@ filterwarnings = [ [dependency-groups] dev = [ - "basedpyright>=1.29.4", - "pytest>=8.4.0", - "ruff>=0.11.13", - "types-pygments>=2.19.0.20250516", + "basedpyright>=1.29.4", + "pytest>=8.4.0", + "ruff>=0.11.13", + "types-pygments>=2.19.0.20250516", ] diff --git a/src/zona/builder.py b/src/zona/builder.py index dd9509f..b19c0c9 100644 --- a/src/zona/builder.py +++ b/src/zona/builder.py @@ -1,17 +1,14 @@ -import shutil from datetime import date -from pathlib import Path - -from feedgen.feed import FeedGenerator - -from zona import markdown as zmd -from zona import util -from zona.config import ZonaConfig -from zona.layout import Layout, discover_layout -from zona.log import get_logger -from zona.metadata import parse_metadata from zona.models import Item, ItemType +from zona.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 zona.log import get_logger logger = get_logger() @@ -21,25 +18,19 @@ class ZonaBuilder: self, cli_root: Path | None = None, cli_output: Path | None = None, - draft: bool = False, ): - logger.debug("Initializing ZonaBuilder.") self.layout: Layout = discover_layout(cli_root, cli_output) self.config: ZonaConfig = ZonaConfig.from_file( self.layout.root / "config.yml" ) - if draft: - self.config.build.include_drafts = True self.items: list[Item] = [] self.item_map: dict[Path, Item] = {} - self.fresh: bool = True def _discover(self): layout = self.layout items: list[Item] = [] base = layout.root / layout.content - logger.debug(f"Discovering content in {base}.") for path in base.rglob("*"): if path.is_file() and not util.should_ignore( path, patterns=self.config.ignore, base=base @@ -51,21 +42,11 @@ 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}.") - item.metadata, item.content = parse_metadata( - path, config=self.config - ) - if item.metadata.ignore or ( - item.metadata.draft - and not self.config.build.include_drafts - ): - continue - if item.metadata.post: + item.metadata, item.content = parse_metadata(path) + if item.metadata.post == True: item.post = True elif item.metadata.post is None: # check if in posts dir? @@ -76,13 +57,11 @@ 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 @@ -94,55 +73,14 @@ class ZonaBuilder: layout.output ) item.url = ( - "" - if rel_url == Path(".") - else rel_url.as_posix() + "" if rel_url == Path(".") else rel_url.as_posix() ) items.append(item) + # print(item) self.items = items - def 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]: + def _build(self): 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 @@ -150,39 +88,15 @@ 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, + root=self.layout.root, template_dir=self.layout.templates, post_list=post_list, ) self.item_map = { item.source.resolve(): item for item in self.items } - - # write code highlighting stylesheet - if self.config.markdown.syntax_highlighting.enabled: - pygments_style = zmd.get_style_defs(self.config) - pygments_path = ( - self.layout.output / "static" / "pygments.css" - ) - util.ensure_parents(pygments_path) - pygments_path.write_text(pygments_style) + # print(item_map) for item in self.item_map.values(): dst = item.destination # print(item) @@ -197,7 +111,6 @@ 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) @@ -208,28 +121,7 @@ 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 da15a22..bbfc612 100644 --- a/src/zona/cli.py +++ b/src/zona/cli.py @@ -1,16 +1,59 @@ -from importlib.metadata import version as __version__ -from pathlib import Path from typing import Annotated - import typer - +from pathlib import Path from zona import server from zona.builder import ZonaBuilder from zona.layout import initialize_site -from zona.log import get_logger, setup_logging +from zona.log import setup_logging app = typer.Typer() -logger = get_logger() + +setup_logging("DEBUG") + + +@app.command() +def build( + root: Annotated[ + Path | None, + typer.Argument( + help="Directory containing config.yml", + ), + ] = None, + output: Annotated[ + Path | None, + typer.Argument(help="Location to write built website"), + ] = None, +): + """ + Build the website. + + Optionally specify the ROOT and OUTPUT directories. + """ + builder = ZonaBuilder(root, output) + builder.build() + + +@app.command() +def serve( + root: Annotated[ + Path | None, + typer.Argument( + help="Directory containing config.yml", + ), + ] = None, + output: Annotated[ + Path | None, + typer.Argument(help="Location to write built website"), + ] = None, +): + """ + Build the website and start a live preview server. + + The website is rebuilt when the source is modified. + + Optionally specify the ROOT and OUTPUT directories. + """ + server.serve(root, output) @app.command() @@ -30,144 +73,8 @@ def init( Optionally specify the ROOT directory. """ - logger.info("Initializing site...") initialize_site(root) -@app.command() -def build( - root: Annotated[ - Path | None, - typer.Argument( - help="Directory containing config.yml", - ), - ] = None, - output: Annotated[ - Path | None, - typer.Option( - "--output", "-o", help="Location to write built website" - ), - ] = None, - draft: Annotated[ - bool, - typer.Option("--draft", "-d", help="Include drafts."), - ] = False, -): - """ - Build the website. - - Optionally specify the ROOT and OUTPUT directories. - """ - if draft: - print("Option override: including drafts.") - builder = ZonaBuilder( - cli_root=root, cli_output=output, draft=draft - ) - builder.build() - - -@app.command() -def serve( - root: Annotated[ - Path | None, - typer.Argument( - help="Directory containing config.yml", - ), - ] = None, - host: Annotated[ - str, - typer.Option( - "--host", help="Hostname for live preview server." - ), - ] = "localhost", - port: Annotated[ - int, - typer.Option( - "--port", - "-p", - help="Port number for live preview server.", - ), - ] = 8000, - output: Annotated[ - Path | None, - typer.Option( - "--output", - "-o", - help="Location to write built website. Temporary directory by default.", - ), - ] = None, - final: Annotated[ - bool, - typer.Option("--final", "-f", help="Don't include drafts."), - ] = False, - live_reload: Annotated[ - bool | None, - typer.Option( - "--live-reload/--no-live-reload", - "-l/-L", - help="Automatically reload web preview. Overrides config.", - show_default=False, - ), - ] = None, -): - """ - Build the website and start a live preview server. - - The website is rebuilt when the source is modified. - - Optionally specify the ROOT and OUTPUT directories. - """ - if final: - print("Preview without drafts.") - else: - print("Preview with drafts.") - if live_reload is None: - reload = None - else: - reload = live_reload - server.serve( - root=root, - output=output, - draft=not final, - host=host, - port=port, - user_reload=reload, - ) - - -def version_callback(value: bool): - if value: - print(f"Zona version: {__version__('zona')}") - raise typer.Exit() - - -@app.callback() -def main_entry( - version: Annotated[ # pyright: ignore[reportUnusedParameter] - bool | None, - typer.Option( - "--version", - callback=version_callback, - is_eager=True, - help="Print version info and exit.", - ), - ] = None, - verbosity: Annotated[ - str, - typer.Option( - "--verbosity", - "-v", - help="Logging verbosity. One of INFO, DEBUG, WARN, ERROR.", - ), - ] = "info", -) -> None: - """ - Opinionated static site generator. - - Supply --help after any subcommand for more details. - """ - setup_logging(verbosity) - - def main(): app() diff --git a/src/zona/config.py b/src/zona/config.py index 84844c7..ed51f43 100644 --- a/src/zona/config.py +++ b/src/zona/config.py @@ -1,47 +1,22 @@ -from __future__ import annotations - from dataclasses import dataclass, field -from datetime import datetime, tzinfo -from pathlib import Path -from typing import Any -from zoneinfo import ZoneInfo - -import yaml -from dacite import Config as DaciteConfig from dacite import from_dict - -from zona.log import get_logger - -logger = get_logger() +import yaml +from pathlib import Path def find_config(start: Path | None = None) -> Path | None: - logger.debug("Searching for config file...") current = (start or Path.cwd()).resolve() for parent in [current, *current.parents]: - candidate = parent / "config.yml" + candidate = parent / "zona.yml" if candidate.is_file(): - logger.debug(f"Config file {candidate} found.") return candidate - logger.debug("Couldn't find config file.") return None -SitemapConfig = dict[str, str] - - -@dataclass -class PostDefaultsConfig: - description: str = "A blog post" - - @dataclass class BlogConfig: dir: str = "blog" - defaults: PostDefaultsConfig = field( - default_factory=PostDefaultsConfig - ) @dataclass @@ -51,19 +26,17 @@ 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 ThemeConfig: + name: str = "default" @dataclass @@ -72,69 +45,23 @@ class BuildConfig: include_drafts: bool = False -@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" - ) +IGNORELIST = [".git", ".env", "*/.marksman.toml"] @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": "/"} - ) + title: str = "Zona Blog" + base_url: str = "https://example.com" + language: str = "en" # list of globs relative to content that should be ignored ignore: list[str] = field(default_factory=lambda: IGNORELIST) markdown: MarkdownConfig = field(default_factory=MarkdownConfig) + theme: ThemeConfig = field(default_factory=ThemeConfig) build: BuildConfig = field(default_factory=BuildConfig) blog: BlogConfig = field(default_factory=BlogConfig) - server: ServerConfig = field(default_factory=ServerConfig) @classmethod - def from_file(cls, path: Path) -> ZonaConfig: + def from_file(cls, path: Path) -> "ZonaConfig": with open(path, "r") as f: raw = yaml.safe_load(f) - config: ZonaConfig = from_dict( - data_class=cls, - data=raw, - config=DaciteConfig(type_hooks={tzinfo: parse_timezone}), - ) - return config + return from_dict(data_class=cls, data=raw) diff --git a/src/zona/data/content/static/style.css b/src/zona/data/content/static/style.css deleted file mode 100644 index caa0eef..0000000 --- a/src/zona/data/content/static/style.css +++ /dev/null @@ -1,523 +0,0 @@ -:root { - --main-placeholder-color: #b14242; - --main-text-color: #b4b4b4; - --main-text-opaque-color: rgba(180, 180, 180, 0.8); - --main-bg-color: #121212; - --main-link-color: #df6464; - --main-heading-color: #df6464; - --main-bullet-color: #d87c4a; - --orange-rgb: rgba(216, 124, 74, 0.6); - --main-transparent: rgba(255, 255, 255, 0.15); - --main-small-text-color: rgba(255, 255, 255, 0.45); -} - -html { - scroll-behavior: smooth; -} - -body { - margin: 0; - line-height: 1.6; - font-size: 18px; - font-family: sans-serif; - background: var(--main-bg-color); - color: var(--main-text-color); - padding-left: calc(100vw - 100%); -} - -header { - padding-top: -1rem; - margin-top: -1rem; - font-family: monospace; - text-transform: lowercase; -} - -.post-nav { - font-family: monospace; - font-size: 0.95em; - white-space: nowrap; -} - -.post-nav .bar { - position: relative; - bottom: 0.05em; - display: inline-block; - width: 1px; - height: 0.8em; - background-color: currentColor; - vertical-align: middle; - margin: 0 0.3em; -} - -.post-nav .placeholder { - color: var(--main-placeholder-color); -} - -.post-nav .symbol { - color: var(--main-bullet-color); - margin: 0; - padding: 0; - display: inline; -} - -.site-logo.hover-symbol::before { - content: "~/"; -} - -.title.hover-symbol::before { - content: "$"; -} - -.hover-symbol { - color: inherit; - position: relative; - font-weight: bold; - text-decoration: none; - transition: color 0.15s ease; -} - -.hover-symbol::before { - font-family: monospace; - content: "#"; - position: absolute; - right: 100%; - margin-right: 0.25em; - top: 50%; - transform: translateY(-50%); - opacity: 0; - transition: opacity 0.15s ease, color 0.15s ease; - color: var(--main-text-color); -} - -.hover-symbol:hover::before { - opacity: 1; - color: var(--main-placeholder-color); -} -.hover-symbol:hover { - background-color: transparent; -} - -.toc ul { - font-family: monospace; - text-transform: lowercase; - margin: auto; - width: 50%; -} - -.toc ul ul { - padding-left: 1em; - margin-left: 1em; - /* list-style-type: "–– ";*/ -} -.toc ul ul ul { - padding-left: 1em; - margin-left: 1em; - /* list-style-type: "-- ";*/ -} - -.toclink { - position: relative; - text-decoration: none; - color: inherit; - transition: color 0.15s ease; - text-transform: lowercase; - font-family: monospace; -} -.post-list a { - position: relative; - text-decoration: none; - transition: color 0.15s ease; - text-transform: lowercase; - font-family: monospace; -} - -.toclink::before { - content: "#"; - position: absolute; - right: 100%; - margin-right: 0.25em; - top: 50%; - transform: translateY(-50%); - opacity: 0; - transition: opacity 0.15s ease, color 0.15s ease; - color: var(--main-link-color); -} - -.toclink:hover::before { - opacity: 1; - color: var(--main-placeholder-color); -} -.toclink:hover { - background-color: transparent; -} - -h1 .toclink::before { - content: "#"; -} - -h2 .toclink::before { - content: "#"; -} - -h3 .toclink::before { - content: "##"; -} - -h4 .toclink::before { - content: "###"; -} - -/* h1, */ -h2, -h3, -h4, -h5, -h6 { - color: var(--main-heading-color); -} - -h1 { - margin-block-start: 0.67rem; - margin-block-end: 0.67rem; - font-size: 2rem; - font-weight: bold; -} - -.title { - text-transform: lowercase; - font-family: monospace; -} - -.title a { - color: inherit; - text-decoration: none; -} - -article h1:first-of-type { - margin-block-start: 1.67rem; -} - -h2 { - margin-block-start: 0.83rem; - margin-block-end: 0.83rem; - font-size: 1.5rem; - font-weight: bold; -} - -h3 { - margin-block-start: 1rem; - margin-block-end: 1rem; - font-size: 1.17em; - font-weight: bold; -} - -h4 { - margin-block-start: 1.33rem; - margin-block-end: 1.33rem; - font-size: 1rem; - font-weight: bold; -} - -article h1 + h4:first-of-type { - margin-block-start: 0rem; -} - -h5 { - margin-block-start: 1.67rem; - margin-block-end: 1.67rem; - font-size: 0.83rem; - font-weight: bold; -} - -h6 { - margin-block-start: 2.33rem; - margin-block-end: 2.33rem; - font-size: 0.67rem; - font-weight: bold; -} - -/*ul {*/ -/* list-style-type: disc;*/ -/*}*/ - -ul { - list-style-type: "– "; -} -ul ul { - padding-left: 1em; - margin-left: 1em; - list-style-type: "+ "; -} -ul ul ul { - list-style-type: "~ "; -} -ul ul ul ul { - list-style-type: "• "; -} -ul ul ul ul ul { - list-style-type: "– "; -} -ul ul ul ul ul ul { - list-style-type: "+ "; -} -ul ul ul ul ul ul ul { - list-style-type: "~ "; -} -ul ul ul ul ul ul ul ul { - list-style-type: "• "; -} - -li::marker { - color: var(--main-bullet-color); -} - -a { - color: var(--main-link-color); - text-decoration: underline; - text-decoration-color: rgba(0, 0, 0, 0); - text-underline-offset: 2px; -} - -a { - transition: color 0.15s ease, text-decoration-color 0.15s ease; -} - -a:hover { - text-decoration-color: var(--main-placeholder-color); - color: var(--main-bullet-color); -} - -max-width: 100%; -overflow: hidden; -img { - display: block; - margin-left: auto; - margin-right: auto; - width: auto; - height: auto; -} - -blockquote { - color: var(--main-text-opaque-color); - border-left: 3px solid var(--orange-rgb); - padding: 0 1rem; - margin-left: 0; - margin-right: 0; -} - -hr { - border: none; - height: 1px; - background: var(--main-small-text-color); - opacity: 0.5; -} - -time { - color: var(--main-bullet-color); - font-family: monospace; -} - -.post-list-date { - font-size: 0.95rem; -} - -code { - background: var(--main-transparent); - border-radius: 0.1875rem; - /* padding: .0625rem .1875rem; */ - /* margin: 0 .1875rem; */ -} - -code, -pre { - white-space: pre; - word-wrap: break-word; - overflow-wrap: break-word; - font-family: - ui-monospace, - SFMono-Regular, - SF Mono, - Menlo, - Consolas, - Liberation Mono, - monospace; - font-size: 0.95em; -} - -pre { - background-color: #1d1d1d; - color: #d5d5d5; - padding: 1em; - border-radius: 5px; - line-height: 1.5; - overflow-x: auto; -} - -/* Inline code styling */ -:not(pre) > code { - padding: 0.2em 0.4em; - font-size: 0.85em; - line-height: 1; - background-color: #1d1d1d; - border-radius: 6px; - vertical-align: middle; - font-family: - ui-monospace, - SFMono-Regular, - SF Mono, - Menlo, - Consolas, - Liberation Mono, - monospace; -} - -/* Block code styling (inherits from pre) */ -pre code { - padding: 0; - border-radius: 0; - background: none; -} - -small { - font-size: 0.95rem; - color: var(--main-small-text-color); -} - -small a { - color: inherit; - /* Inherit the color of the surrounding text */ - text-decoration: underline; - /* Optional: Keep the underline to indicate a link */ -} - -.title-container { - display: flex; - justify-content: center; - align-items: center; - text-align: center; -} - -.title-container h1 { - margin: 0; -} - -.image-container { - text-align: center; - margin: 20px 0; - max-width: 100%; - overflow: hidden; - /* Optional: add some spacing around the image container */ -} - -.image-container img { - max-width: 100%; - width: auto; - max-height: 100%; - height: auto; -} - -.fixed .image-container img { - max-width: 308px; - max-height: 308px; -} - -.image-container small { - display: block; - /* Ensure the caption is on a new line */ - margin-top: 5px; - /* Optional: adjust spacing between image and caption */ -} - -.image-container small a { - color: inherit; - /* Ensure the link color matches the small text */ - text-decoration: underline; - /* Optional: underline to indicate a link */ -} - -#header ul { - list-style-type: none; - padding-left: 0; -} - -#header li { - display: inline; - font-size: 1.2rem; - margin-right: 1.2rem; -} - -#container { - margin: 2.5rem auto; - width: 90%; - max-width: 60ch; -} - -#postlistdiv ul { - list-style-type: none; - padding-left: 0; -} - -.moreposts { - font-size: 0.95rem; - padding-left: 0.5rem; -} - -#nextprev { - text-align: center; - margin-top: 1.4rem; - font-size: 0.95rem; -} - -#footer { - color: var(--main-small-text-color); -} -table { - border-collapse: collapse; - margin: 1.5rem auto; - width: 100%; - max-width: 100%; - font-size: 0.85rem; - text-align: left; /* Use center if you prefer */ -} - -th, td { - border: 1px solid var(--main-transparent); - /*border: 1px solid var(--main-bullet-color);*/ - padding: 0.4rem 0.8rem; - vertical-align: middle; -} - -thead th { - font-weight: bold; - background-color: rgba(255, 255, 255, 0.05); - color: var(--main-text-color); -} - -tbody tr:nth-child(even) { - background-color: rgba(255, 255, 255, 0.02); -} - -tbody tr:hover { - background-color: rgba(255, 255, 255, 0.05); -} - -table code { - font-family: - ui-monospace, - SFMono-Regular, - SF Mono, - Menlo, - Consolas, - Liberation Mono, - monospace; - font-size: 0.85em; - background: #1d1d1d; - padding: 0.1em 0.25em; - border-radius: 3px; -} - -caption { - margin-top: 0.5rem; - font-size: 0.8rem; - color: var(--main-small-text-color); -} diff --git a/src/zona/data/server/inject.js b/src/zona/data/server/inject.js deleted file mode 100644 index bc00dde..0000000 --- a/src/zona/data/server/inject.js +++ /dev/null @@ -1,25 +0,0 @@ -(() => { - // if user at the bottom before reload, scroll to new bottom - if (localStorage.getItem("wasAtBottom") === "1") { - localStorage.removeItem("wasAtBottom"); - window.addEventListener("load", () => { - requestAnimationFrame(() => { - window.scrollTo(0, document.body.scrollHeight); - }); - }); - } - - const ws = new WebSocket("__SOCKET_ADDRESS__"); - const tol = __SCROLL_TOLERANCE__; - ws.onmessage = event => { - if (event.data === "reload") { - // store flag if user currently at bottom - const nearBottom = window.innerHeight + window.scrollY - >= document.body.scrollHeight - tol; - if (nearBottom) { - localStorage.setItem("wasAtBottom", "1"); - } - location.reload(); - } - }; -})(); diff --git a/src/zona/data/templates/base.html b/src/zona/data/templates/base.html index 86769f5..87a8392 100644 --- a/src/zona/data/templates/base.html +++ b/src/zona/data/templates/base.html @@ -10,12 +10,6 @@ type="text/css" media="all" /> -
diff --git a/src/zona/data/templates/basic.html b/src/zona/data/templates/basic.html deleted file mode 100644 index d86a25a..0000000 --- a/src/zona/data/templates/basic.html +++ /dev/null @@ -1,10 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -{% if metadata.show_title %} -{% include "title.html" %} -{% endif %} -{{ content | safe }} -{% endblock %} - - diff --git a/src/zona/data/templates/footer.md b/src/zona/data/templates/footer.md index e18a549..653482d 100644 --- a/src/zona/data/templates/footer.md +++ b/src/zona/data/templates/footer.md @@ -1 +1 @@ -My Markdown footer! +The footer content. diff --git a/src/zona/data/templates/header.html b/src/zona/data/templates/header.html deleted file mode 100644 index f058c85..0000000 --- a/src/zona/data/templates/header.html +++ /dev/null @@ -1,5 +0,0 @@ -
    -{% for name, url in site_map.items() %} -
  • {{ name }}
  • -{% endfor %} -
diff --git a/src/zona/data/templates/header.md b/src/zona/data/templates/header.md new file mode 100644 index 0000000..a0e3630 --- /dev/null +++ b/src/zona/data/templates/header.md @@ -0,0 +1,2 @@ +- One +- Two diff --git a/src/zona/data/templates/page.html b/src/zona/data/templates/page.html index 0ff499b..91b2616 100644 --- a/src/zona/data/templates/page.html +++ b/src/zona/data/templates/page.html @@ -1,12 +1,10 @@ -{% 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 -%} -
+{% extends "base.html" %} +{% 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 30ea74f..5990e6b 100644 --- a/src/zona/data/templates/post_list.html +++ b/src/zona/data/templates/post_list.html @@ -1,18 +1,13 @@ -{% extends "base.html" %} {% block content %} {% if metadata.show_title %} {% -include "title.html" %} {% endif %} - -
{{ content | safe }}
+{% extends "base.html" %} +{% block content %} +

{{ metadata.title }}

{% 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 deleted file mode 100644 index 1e96ff5..0000000 --- a/src/zona/data/templates/post_nav.html +++ /dev/null @@ -1,11 +0,0 @@ -
-
- <{% 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 deleted file mode 100644 index 1541095..0000000 --- a/src/zona/data/templates/title.html +++ /dev/null @@ -1,2 +0,0 @@ -

{{ metadata.title }}

- diff --git a/src/zona/layout.py b/src/zona/layout.py index f4c7311..f98aeab 100644 --- a/src/zona/layout.py +++ b/src/zona/layout.py @@ -1,86 +1,32 @@ -from dataclasses import asdict, dataclass from pathlib import Path -from zoneinfo import ZoneInfo - -import typer -import yaml - -from zona import log, util +from dataclasses import dataclass, asdict from zona.config import ZonaConfig, find_config - -logger = log.get_logger() +from zona import util +import yaml @dataclass class Layout: root: Path content: Path - templates: Path + templates: Path | None 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, - shared_templates=None, - _validate=validate, + output=(root / "public").resolve() if not output else output, ) if validate: - logger.debug("Validating site layout...") if not layout.content.is_dir(): - logger.error("Missing required content directory!") raise FileNotFoundError( "Missing required content directory!" ) - internal_templates = util.get_resource_dir("templates") - user_templates = layout.templates - if not user_templates.is_dir() or util.is_empty( - user_templates - ): - logger.debug("Using default template directory.") - # use the included defaults - layout.templates = 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 @@ -89,71 +35,44 @@ def discover_layout( cli_root: Path | None = None, cli_output: Path | None = None ) -> Layout: if cli_root: - logger.debug("Using user provided site root.") root = cli_root else: - logger.debug("Discovering site layout...") config = find_config(cli_root) if config: root = config.parent else: - logger.debug("Using CWD as root.") root = Path.cwd() return Layout.from_input(root, cli_output) def initialize_site(root: Path | None = None): - logger.info("Initializing site.") # initialize a new project if not root: - logger.debug("No root provided; using CWD.") root = Path.cwd() root = root.absolute().resolve() config = find_config(root) if config is not None: - ans = typer.confirm( - text=( - f"A config file already exists at {config}.\n" - f"Delete it and restore defaults?" - ) - ) - if ans: - logger.debug("Unlinking config file.") - config.unlink() + raise FileExistsError(f"Config file already exists at {config}") # create requires layout - logger.debug("Generating layout.") layout = Layout.from_input(root=root, validate=False) # load template resources - logger.debug("Loading internal templates.") - # only write the footer - templates = [util.get_resource("templates/footer.md")] - logger.debug("Loading internal static content.") - static = util.get_resources("content") + templates = util.get_resources("templates") for dir, resources in [ (layout.root, None), - (layout.content, static), + (layout.content, None), (layout.templates, templates), ]: if not dir.is_dir(): - logger.debug(f"Creating {dir}.") dir.mkdir() if resources is not None: - logger.debug("Writing resources.") for r in resources: - logger.debug(f"Writing {(root / Path(r.name))}.") - (root / Path(r.name)).write_text(r.contents) + Path(r.name).write_text(r.contents) config_path = layout.root / "config.yml" - logger.debug("Loading default configuation.") config = ZonaConfig() - logger.debug(f"Writing default configuration to {config_path}.") - config_dict = asdict(config) - if "feed" in config_dict and "timezone" in config_dict["feed"]: - tz: ZoneInfo = config_dict["feed"]["timezone"] - config_dict["feed"]["timezone"] = tz.key with open(config_path, "w") as f: yaml.dump( - config_dict, + asdict(config), f, sort_keys=False, default_flow_style=False, diff --git a/src/zona/log.py b/src/zona/log.py index 545ec49..f3a3f25 100644 --- a/src/zona/log.py +++ b/src/zona/log.py @@ -1,12 +1,12 @@ import logging - +from typing import Literal from rich.logging import RichHandler _LOGGER_NAME = "zona" def setup_logging( - level: str = "INFO", + level: Literal["INFO", "DEBUG", "WARN", "ERROR"] = "INFO", ): logger = logging.getLogger(_LOGGER_NAME) logger.setLevel(level.upper()) diff --git a/src/zona/markdown.py b/src/zona/markdown.py index 1132f22..774a90c 100644 --- a/src/zona/markdown.py +++ b/src/zona/markdown.py @@ -1,80 +1,25 @@ -import xml.etree.ElementTree as etree -from collections.abc import Sequence -from logging import Logger -from pathlib import Path +from rich import print 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 pathlib import Path +from marko.inline import Link, Image +from marko.block import FencedCode +from marko.html_renderer import HTMLRenderer +from marko.parser import Parser from zona.config import ZonaConfig from zona.layout import Layout -from zona.log import get_logger -from zona.metadata import Metadata + +from pygments import highlight +from pygments.lexers import get_lexer_by_name, TextLexer +from pygments.formatters import HtmlFormatter + +from zona import util from zona.models import Item +from zona.log import get_logger + +logger = get_logger() -class ZonaImageTreeprocessor(Treeprocessor): - """Implement Zona's image caption rendering.""" - - def __init__(self, md: Markdown): - super().__init__() - self.md: Markdown = md - self.logger: Logger = get_logger() - - @override - def run(self, root: etree.Element): - for parent in root.iter(): - for idx, child in enumerate(list(parent)): - if ( - child.tag == "p" - and len(child) == 1 - and child[0].tag == "img" - ): - img = child[0] - div = etree.Element( - "div", {"class": "image-container"} - ) - div.append(img) - title = img.attrib.get("alt", "") - if title: - raw_caption = self.md.convert(title) - caption_html = raw_caption.strip() - if caption_html.startswith( - "

" - ) and caption_html.endswith("

"): - caption_html = caption_html[3:-4] - caption = etree.Element("small") - caption.text = "" # should be rendered - caption_html_element = etree.fromstring( - f"{caption_html}" - ) - caption.append(caption_html_element) - div.append(caption) - parent[idx] = div - - -class ZonaLinkTreeprocessor(Treeprocessor): +class ZonaRenderer(HTMLRenderer): def __init__( self, config: ZonaConfig | None, @@ -85,7 +30,6 @@ 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 @@ -96,66 +40,74 @@ class ZonaLinkTreeprocessor(Treeprocessor): self.config: ZonaConfig | None = config @override - def run(self, root: etree.Element): - for element in root.iter("a"): - href = element.get("href") - if not href: - continue - if self.resolve: - assert self.config - cur = Path(href) - _href = href - same_file = False - resolved = Path() - # href starting with anchor reference the current file - if href.startswith("#"): - same_file = True - elif href.startswith("/"): - # resolve relative to content root - resolved = ( - self.layout.content / cur.relative_to("/") - ).resolve() + def render_link(self, element: Link): + href = element.dest + assert isinstance(href, str) + if self.resolve: + cur = Path(href) + _href = href + if href.startswith("/"): + # resolve relative to content root + resolved = ( + self.layout.content / cur.relative_to("/") + ).resolve() + else: + # treat as relative link and try to resolve + resolved = (self.source.parent / cur).resolve() + # only substitute if link points to an actual file + if resolved.exists(): + item = self.item_map.get(resolved) + if item: + href = util.normalize_url(item.url) + logger.debug( + f"Link in file {self.source}: {_href} resolved to {href}" + ) 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 - # that isn't the self file - if not same_file and internal: - item = self.item_map.get(resolved) - if item: - href = util.normalize_url(item.url) - # don't sub if it's already correct lol - if _href != href: - element.set("href", href) - self.logger.debug( - f"Link in file {self.source}: {_href} resolved to {href}" - ) - else: - self.logger.debug( - f"Warning: resolved path {resolved} not found in item map" - ) - # open link in new tab if not self-link - elif ( - self.config.markdown.links.external_new_tab - and not same_file - ): - element.set("target", "_blank") + logger.debug( + f"Warning: resolved path {resolved} not found in item map" + ) + body: Any = self.render_children(element) + return f'{body}' + # TODO: image compression/dithering? + @override + def render_image(self, element: Image): + # get label text from children + text = self.render_children(element) + title = element.title or "" + caption = f"{text}" if text else "" + return ( + f'
\n' + # TODO: convert to plaintext and add as alt attribute + f'\n' + f"{caption}
" + ) -def get_formatter(config: ZonaConfig): - c = config.markdown.syntax_highlighting - formatter = HtmlFormatter( - style=c.theme, nowrap=not c.wrap, nobackground=True - ) - return formatter + @override + def render_fenced_code(self, element: FencedCode): + assert self.config + config = self.config.markdown.syntax_highlighting + code = "".join(child.children for child in element.children) # type: ignore + lang = element.lang or "text" + if not config.enabled: + return f"
{code}
" + + try: + lexer = get_lexer_by_name(lang, stripall=False) + except Exception: + lexer = TextLexer(stripall=False) # type: ignore + + formatter = HtmlFormatter( + style=config.theme, + nowrap=not config.wrap, + noclasses=True, + ) + highlighted = highlight(code, lexer, formatter) # type: ignore + + return ( + f'
'
+            f"{highlighted}
" + ) def md_to_html( @@ -165,71 +117,20 @@ 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] = [ - BetterEmExtension(), - SuperFencesCodeExtension( - disable_indented_code_blocks=True, - css_class="codehilite", - ), - FootnoteExtension(), - AttrListExtension(), - DefListExtension(), - TocExtension( - anchorlink=True, - ), - TableExtension(), - AbbrExtension(), - SmartyExtension(), - InsertSupExtension(), - DeleteSubExtension(), - SmartSymbolsExtension(), - SaneListExtension(), - MarkdownInHtmlExtension(), - EscapeAllExtension(hardbreak=True), - ] - kwargs: dict[str, Any] = { - "extensions": extensions, - "tab_length": 2, - } - if metadata and metadata.math: - kwargs["extensions"].append(LaTeX2MathMLExtension()) - if config: - kwargs["extensions"].extend( - [ - CodeHiliteExtension( - linenums=False, - noclasses=False, - pygments_style=config.markdown.syntax_highlighting.theme, - ), - InlineHiliteExtension(css_class="codehilite"), - ] + if resolve_links and ( + source is None or layout is None or item_map is None + ): + raise TypeError( + "md_to_html() missing source and ctx when resolve_links is true" ) - kwargs["tab_length"] = config.markdown.tab_length - md = Markdown(**kwargs) - if resolve_links: - if source is None or layout is None or item_map is None: - raise TypeError( - "md_to_html() missing source and ctx when resolve_links is true" - ) - md.treeprocessors.register( - item=ZonaLinkTreeprocessor( - config, resolve_links, source, layout, item_map - ), - name="zona_links", - priority=15, - ) - md.treeprocessors.register( - item=ZonaImageTreeprocessor(md), - name="zona_images", - priority=17, + parser = Parser() + ast = parser.parse(content) + renderer = ZonaRenderer( + config, + resolve_links, + source, + layout=layout, + item_map=item_map, ) - return md.convert(content) - - -def get_style_defs(config: ZonaConfig) -> str: - formatter = get_formatter(config) - defs = formatter.get_style_defs(".codehilite") - assert isinstance(defs, str) - return defs + return renderer.render(ast) diff --git a/src/zona/metadata.py b/src/zona/metadata.py index 4551117..de2233e 100644 --- a/src/zona/metadata.py +++ b/src/zona/metadata.py @@ -1,59 +1,29 @@ from dataclasses import dataclass -from datetime import date, datetime, time, tzinfo +from rich import print from pathlib import Path +from datetime import date -import frontmatter from dacite.config import Config from dacite.core import from_dict from dacite.exceptions import DaciteError -from dateutil import parser as date_parser from yaml import YAMLError - import zona.util -from zona.config import ZonaConfig +import frontmatter @dataclass class Metadata: title: str - date: datetime - description: str - show_title: bool = True - show_date: bool = True - show_nav: bool = True + date: date + description: str | None style: str | None = "/static/style.css" header: bool = True footer: bool = True - template: str | None = None + template: str = "page.html" post: bool | None = None - draft: bool = False - ignore: bool = False - math: bool = True -def ensure_timezone(dt: datetime, tz: tzinfo) -> datetime: - if dt.tzinfo is None or dt.utcoffset() is None: - dt = dt.replace(tzinfo=tz) - return dt - - -# TODO: migrate to using datetime, where user can optionall specify -# a time as well. if only date is given, default to time.min -def parse_date( - raw_date: str | datetime | date | object, tz: tzinfo -) -> datetime: - if isinstance(raw_date, datetime): - return ensure_timezone(raw_date, tz) - elif isinstance(raw_date, date): - return datetime.combine(raw_date, time.min, tzinfo=tz) - assert isinstance(raw_date, str) - dt = date_parser.parse(raw_date) - return ensure_timezone(dt, tz) - - -def parse_metadata( - path: Path, config: ZonaConfig -) -> tuple[Metadata, str]: +def parse_metadata(path: Path) -> 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 @@ -69,11 +39,9 @@ def parse_metadata( raw_meta = post.metadata or {} defaults = { "title": zona.util.filename_to_title(path), - "date": datetime.fromtimestamp(path.stat().st_ctime), - "description": config.blog.defaults.description, + "date": date.fromtimestamp(path.stat().st_mtime), } meta = {**defaults, **raw_meta} - 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 7f67015..785f02f 100644 --- a/src/zona/models.py +++ b/src/zona/models.py @@ -1,8 +1,6 @@ -from __future__ import annotations - -from dataclasses import dataclass -from enum import Enum from pathlib import Path +from enum import Enum +from dataclasses import dataclass from zona.metadata import Metadata @@ -23,8 +21,6 @@ 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 f8b0000..e4a7cbd 100644 --- a/src/zona/server.py +++ b/src/zona/server.py @@ -1,124 +1,26 @@ -import io -import os import signal +import os import sys -import tempfile -import threading -from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path from types import FrameType +from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler +import threading 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 watchdog.events import FileSystemEventHandler, FileSystemEvent +from pathlib import Path 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] @@ -129,33 +31,12 @@ class ZonaServer(ThreadingHTTPServer): class ZonaReloadHandler(FileSystemEventHandler): - """FileSystemEventHandler that rebuilds the website - and triggers the browser into refreshing over WebSocket.""" - - def __init__( - self, - builder: ZonaBuilder, - output: Path, - ws_server: WebSocketServer | None, - ): + def __init__(self, builder: ZonaBuilder, output: Path): 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 @@ -164,97 +45,56 @@ class ZonaReloadHandler(FileSystemEventHandler): @override def on_modified(self, event: FileSystemEvent): - self._trigger_rebuild(event) + if not self._should_ignore(event): + logger.info(f"Modified: {event.src_path}, rebuilding...") + self.builder.build() @override def on_created(self, event: FileSystemEvent): - self._trigger_rebuild(event) + if not self._should_ignore(event): + logger.info(f"Modified: {event.src_path}, rebuilding...") + self.builder.build() + + +def run_http_server(dir: Path, host: str = "localhost", port: int = 8000): + os.chdir(dir) + handler = QuietHandler + httpd = ZonaServer( + server_address=(host, port), RequestHandlerClass=handler + ) + logger.info(f"Serving {dir} at http://{host}:{port}") + logger.info(f"Exit with ") + httpd.serve_forever() def serve( root: Path | None = None, output: Path | None = None, - draft: bool = True, host: str = "localhost", port: int = 8000, - user_reload: bool | None = None, ): - """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 + builder = ZonaBuilder(root, output) + builder.build() + if output is None: + output = builder.layout.output + if root is None: + root = builder.layout.root - # 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 ") + server_thread = threading.Thread( + target=run_http_server, args=(output, host, port), daemon=True + ) + server_thread.start() - # start server in a thread - server_thread = threading.Thread( - target=httpd.serve_forever, daemon=True - ) - server_thread.start() + event_handler = ZonaReloadHandler(builder, output) + observer = Observer() + observer.schedule(event_handler, path=str(root), recursive=True) + observer.start() - # 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() + def shutdown_handler(_a: int, _b: FrameType | None): + logger.info("Shutting down...") + observer.stop() - # function to shut down gracefully - def shutdown_handler(_a: int, _b: FrameType | None): - print("Shutting down...") - observer.stop() - httpd.shutdown() + signal.signal(signal.SIGINT, shutdown_handler) + signal.signal(signal.SIGTERM, shutdown_handler) - # register shutdown handler - signal.signal(signal.SIGINT, shutdown_handler) - signal.signal(signal.SIGTERM, shutdown_handler) - - # start file change watcher - observer.join() + observer.join() diff --git a/src/zona/templates.py b/src/zona/templates.py index 4529143..e63fc1c 100644 --- a/src/zona/templates.py +++ b/src/zona/templates.py @@ -1,72 +1,79 @@ +from dataclasses import dataclass 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.markdown import md_to_html +from jinja2.environment import Template from zona.models import Item +from zona.markdown import md_to_html +from zona import util -def get_header(template_dir: Path) -> str | None: - md_header = template_dir / "header.md" - html_header = template_dir / "header.html" +def get_header(root: Path) -> str | None: + md_header = root / "header.md" + html_header = root / "header.html" if md_header.exists(): return md_to_html(md_header.read_text(), None) elif html_header.exists(): return html_header.read_text() -def get_footer(template_dir: Path) -> str | None: - md_footer = template_dir / "footer.md" - html_footer = template_dir / "footer.html" +def get_footer(root: Path) -> str | None: + md_footer = root / "footer.md" + html_footer = root / "footer.html" if md_footer.exists(): return md_to_html(md_footer.read_text(), None) elif html_footer.exists(): return html_footer.read_text() +class TemplateEnv: + def __init__(self, user_dir: Path | None): + default_path = util.get_resource_dir("templates") + self.default: Environment = Environment( + loader=FileSystemLoader(default_path), + autoescape=select_autoescape(["html", "xml"]), + ) + self.user: Environment | None = ( + Environment( + loader=FileSystemLoader(user_dir), + autoescape=select_autoescape(["html", "xml"]), + ) + if user_dir + else None + ) + + def get_template(self, name: str) -> Template: + for env in ( + [self.user, self.default] if self.user else [self.default] + ): + if name in env.list_templates(): + return env.get_template(name) + raise FileNotFoundError(f"Template {name} could not be found!") + + +# TODO: add next/prev post button logic to posts # TODO: add a recent posts element that can be included elsewhere? class Templater: def __init__( - self, - config: ZonaConfig, - template_dir: Path, - post_list: list[Item], + self, root: Path, template_dir: Path | None, post_list: list[Item] ): - # build temporary template dir - self.env: Environment = Environment( - loader=FileSystemLoader(template_dir), - autoescape=select_autoescape(["html", "xml"]), - ) - self.config: ZonaConfig = config - self.template_dir: Path = template_dir - self.footer: str | None = get_footer(template_dir) + self.env: TemplateEnv = TemplateEnv(template_dir) + self.root: Path = root + self.header: str | None = get_header(self.root) + self.footer: str | None = get_footer(self.root) self.post_list: list[Item] = post_list - def render_header(self): - template = self.env.get_template("header.html") - return template.render(site_map=self.config.sitemap) - def render_item(self, item: Item, content: str) -> str: env = self.env meta = item.metadata assert meta is not None - if meta.template is None: - if item.post: - template_name = "page.html" - else: - template_name = "basic.html" - else: - template_name = ( - meta.template - if meta.template.endswith(".html") - else meta.template + ".html" - ) - template = env.get_template(template_name) + template = env.get_template( + meta.template + if meta.template.endswith(".html") + else meta.template + ".html" + ) header: str | Literal[False] = ( - self.render_header() if meta.header else False + self.header if self.header and meta.header else False ) footer: str | Literal[False] = ( self.footer if self.footer and meta.footer else False @@ -77,12 +84,5 @@ 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 5bf6f53..823180f 100644 --- a/src/zona/util.py +++ b/src/zona/util.py @@ -1,36 +1,10 @@ -import fnmatch -import re -import shutil -import string -import tempfile -import weakref +from typing import NamedTuple +from rich import print from importlib import resources -from importlib.resources.abc import Traversable +import fnmatch from pathlib import Path from shutil import copy2 -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"" +import string class ZonaResource(NamedTuple): @@ -38,36 +12,16 @@ 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] = [] - base = resources.files("zona").joinpath("data", subdir) - - def walk(trav: Traversable, prefix: str = ""): - for item in trav.iterdir(): - path = f"{prefix}{item.name}" - if item.is_dir(): - walk(item, prefix=f"{path}/") - else: - out.append( - ZonaResource( - name=f"{subdir}/{path}", - contents=item.read_text(), - ) - ) - - walk(base) + for resource in ( + resources.files("zona").joinpath(f"data/{subdir}").iterdir() + ): + out.append( + ZonaResource(f"{subdir}/{resource.name}", resource.read_text()) + ) + print(out) return out @@ -90,15 +44,6 @@ 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("_", " ") @@ -111,31 +56,6 @@ 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 - ) - - -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() + return any(fnmatch.fnmatch(str(rel_path), pattern) for pattern in patterns) diff --git a/src/zona/websockets.py b/src/zona/websockets.py deleted file mode 100644 index 2889af2..0000000 --- a/src/zona/websockets.py +++ /dev/null @@ -1,68 +0,0 @@ -import asyncio -from threading import Thread - -from websockets.legacy.server import WebSocketServerProtocol, serve - - -class WebSocketServer: - """ - Async WebSocket server for live reloading. - Notifies clients when they should reload. - """ - - host: str - port: int - clients: set[WebSocketServerProtocol] - loop: asyncio.AbstractEventLoop | None - thread: Thread | None - - def __init__(self, host: str = "localhost", port: int = 8765): - self.host = host - self.port = port - self.clients = set() - self.loop = None - self.thread = None - - async def _handler(self, ws: WebSocketServerProtocol): - """Handle incoming connections by adding to client set.""" - self.clients.add(ws) - try: - await ws.wait_closed() - finally: - self.clients.remove(ws) - - def start(self): - """Spin up server.""" - - def run(): - # set up async event loop - self.loop = asyncio.new_event_loop() - asyncio.set_event_loop(self.loop) - # start server - ws_server = serve( - ws_handler=self._handler, - host=self.host, - port=self.port, - ) - # add server to event loop - self.loop.run_until_complete(ws_server) - self.loop.run_forever() - - # spawn async serever in a thread - self.thread = Thread(target=run, daemon=True) - self.thread.start() - - async def _broadcast(self, message: str): - """Broadcast message to all connected clients.""" - for ws in self.clients.copy(): - try: - await ws.send(message) - except Exception: - self.clients.discard(ws) - - def notify_all(self, message: str = "reload"): - """Notify all connected clients.""" - if self.loop and self.clients: - asyncio.run_coroutine_threadsafe( - coro=self._broadcast(message), loop=self.loop - ) diff --git a/tests/test_builder.py b/tests/test_builder.py index bd0c9b7..61e148e 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -1,10 +1,8 @@ -from datetime import date -from pathlib import Path - import pytest - -from zona.builder import build, discover, split_metadata +from datetime import date from zona.metadata import Metadata +from zona.builder import split_metadata, discover, build +from pathlib import Path def test_split_metadata(tmp_path: Path): diff --git a/tests/test_util.py b/tests/test_util.py index 2c1a361..2837df4 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,5 +1,4 @@ from pathlib import Path - from zona import util diff --git a/uv.lock b/uv.lock index c81743b..b3a82bc 100644 --- a/uv.lock +++ b/uv.lock @@ -44,16 +44,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/35/386550fd60316d1e37eccdda609b074113298f23cef5bddb2049823fe666/dacite-1.9.2-py3-none-any.whl", hash = "sha256:053f7c3f5128ca2e9aceb66892b1a3c8936d02c686e707bee96e19deef4bc4a0", size = 16600, upload-time = "2025-02-05T09:27:24.345Z" }, ] -[[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" @@ -75,77 +65,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] -[[package]] -name = "l2m4m" -version = "1.0.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "latex2mathml" }, - { name = "markdown" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1d/71/2548c288ef1b27f3419285e45430b8f10b9be8f4c34b4d2ecec2d34baf42/l2m4m-1.0.4.tar.gz", hash = "sha256:7fc451edb281604c1eb9d6fa626a8cce874e8a0d3641cca581c20b7544f51396", size = 16567, upload-time = "2024-06-15T16:49:04.798Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/8a/500639fe4b0b1dfc7775633b9173d96a474bbc0811578e916595c9b323f0/L2M4M-1.0.4-py3-none-any.whl", hash = "sha256:766e232b28cfdc6397e9c47162a938dba26e5ab90bddec8d82eccb7896ab46d1", size = 14985, upload-time = "2024-06-15T16:49:03.88Z" }, -] - -[[package]] -name = "latex2mathml" -version = "3.78.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/33/ad2c3929494ad160f5130ea132ca298627a6c81c70be6bedd1bc806b5b01/latex2mathml-3.78.0.tar.gz", hash = "sha256:712193aa4c6ade1a8e0145dac7bc1f9aafbd54f93046a2356a7e1c05fa0f8b31", size = 73737, upload-time = "2025-05-03T16:51:53.563Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/fd/aba08bb9e527168efad57985d7db9a853eb2384b1efa5ca5f3a3794c9cef/latex2mathml-3.78.0-py3-none-any.whl", hash = "sha256:1aeca3dc027b3006ad7b301b7f4a15ffbb4c1451e3dc8c3389e97b37b497e1d6", size = 73673, upload-time = "2025-05-03T16:51:51.991Z" }, -] - -[[package]] -name = "lxml" -version = "6.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c5/ed/60eb6fa2923602fba988d9ca7c5cdbd7cf25faa795162ed538b527a35411/lxml-6.0.0.tar.gz", hash = "sha256:032e65120339d44cdc3efc326c9f660f5f7205f3a535c1fdbf898b29ea01fb72", size = 4096938, upload-time = "2025-06-26T16:28:19.373Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/c3/d01d735c298d7e0ddcedf6f028bf556577e5ab4f4da45175ecd909c79378/lxml-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78718d8454a6e928470d511bf8ac93f469283a45c354995f7d19e77292f26108", size = 8429515, upload-time = "2025-06-26T16:26:06.776Z" }, - { url = "https://files.pythonhosted.org/packages/06/37/0e3eae3043d366b73da55a86274a590bae76dc45aa004b7042e6f97803b1/lxml-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:84ef591495ffd3f9dcabffd6391db7bb70d7230b5c35ef5148354a134f56f2be", size = 4601387, upload-time = "2025-06-26T16:26:09.511Z" }, - { url = "https://files.pythonhosted.org/packages/a3/28/e1a9a881e6d6e29dda13d633885d13acb0058f65e95da67841c8dd02b4a8/lxml-6.0.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:2930aa001a3776c3e2601cb8e0a15d21b8270528d89cc308be4843ade546b9ab", size = 5228928, upload-time = "2025-06-26T16:26:12.337Z" }, - { url = "https://files.pythonhosted.org/packages/9a/55/2cb24ea48aa30c99f805921c1c7860c1f45c0e811e44ee4e6a155668de06/lxml-6.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:219e0431ea8006e15005767f0351e3f7f9143e793e58519dc97fe9e07fae5563", size = 4952289, upload-time = "2025-06-28T18:47:25.602Z" }, - { url = "https://files.pythonhosted.org/packages/31/c0/b25d9528df296b9a3306ba21ff982fc5b698c45ab78b94d18c2d6ae71fd9/lxml-6.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bd5913b4972681ffc9718bc2d4c53cde39ef81415e1671ff93e9aa30b46595e7", size = 5111310, upload-time = "2025-06-28T18:47:28.136Z" }, - { url = "https://files.pythonhosted.org/packages/e9/af/681a8b3e4f668bea6e6514cbcb297beb6de2b641e70f09d3d78655f4f44c/lxml-6.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:390240baeb9f415a82eefc2e13285016f9c8b5ad71ec80574ae8fa9605093cd7", size = 5025457, upload-time = "2025-06-26T16:26:15.068Z" }, - { url = "https://files.pythonhosted.org/packages/99/b6/3a7971aa05b7be7dfebc7ab57262ec527775c2c3c5b2f43675cac0458cad/lxml-6.0.0-cp312-cp312-manylinux_2_27_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d6e200909a119626744dd81bae409fc44134389e03fbf1d68ed2a55a2fb10991", size = 5657016, upload-time = "2025-07-03T19:19:06.008Z" }, - { url = "https://files.pythonhosted.org/packages/69/f8/693b1a10a891197143c0673fcce5b75fc69132afa81a36e4568c12c8faba/lxml-6.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca50bd612438258a91b5b3788c6621c1f05c8c478e7951899f492be42defc0da", size = 5257565, upload-time = "2025-06-26T16:26:17.906Z" }, - { url = "https://files.pythonhosted.org/packages/a8/96/e08ff98f2c6426c98c8964513c5dab8d6eb81dadcd0af6f0c538ada78d33/lxml-6.0.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:c24b8efd9c0f62bad0439283c2c795ef916c5a6b75f03c17799775c7ae3c0c9e", size = 4713390, upload-time = "2025-06-26T16:26:20.292Z" }, - { url = "https://files.pythonhosted.org/packages/a8/83/6184aba6cc94d7413959f6f8f54807dc318fdcd4985c347fe3ea6937f772/lxml-6.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afd27d8629ae94c5d863e32ab0e1d5590371d296b87dae0a751fb22bf3685741", size = 5066103, upload-time = "2025-06-26T16:26:22.765Z" }, - { url = "https://files.pythonhosted.org/packages/ee/01/8bf1f4035852d0ff2e36a4d9aacdbcc57e93a6cd35a54e05fa984cdf73ab/lxml-6.0.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:54c4855eabd9fc29707d30141be99e5cd1102e7d2258d2892314cf4c110726c3", size = 4791428, upload-time = "2025-06-26T16:26:26.461Z" }, - { url = "https://files.pythonhosted.org/packages/29/31/c0267d03b16954a85ed6b065116b621d37f559553d9339c7dcc4943a76f1/lxml-6.0.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c907516d49f77f6cd8ead1322198bdfd902003c3c330c77a1c5f3cc32a0e4d16", size = 5678523, upload-time = "2025-07-03T19:19:09.837Z" }, - { url = "https://files.pythonhosted.org/packages/5c/f7/5495829a864bc5f8b0798d2b52a807c89966523140f3d6fa3a58ab6720ea/lxml-6.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:36531f81c8214e293097cd2b7873f178997dae33d3667caaae8bdfb9666b76c0", size = 5281290, upload-time = "2025-06-26T16:26:29.406Z" }, - { url = "https://files.pythonhosted.org/packages/79/56/6b8edb79d9ed294ccc4e881f4db1023af56ba451909b9ce79f2a2cd7c532/lxml-6.0.0-cp312-cp312-win32.whl", hash = "sha256:690b20e3388a7ec98e899fd54c924e50ba6693874aa65ef9cb53de7f7de9d64a", size = 3613495, upload-time = "2025-06-26T16:26:31.588Z" }, - { url = "https://files.pythonhosted.org/packages/0b/1e/cc32034b40ad6af80b6fd9b66301fc0f180f300002e5c3eb5a6110a93317/lxml-6.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:310b719b695b3dd442cdfbbe64936b2f2e231bb91d998e99e6f0daf991a3eba3", size = 4014711, upload-time = "2025-06-26T16:26:33.723Z" }, - { url = "https://files.pythonhosted.org/packages/55/10/dc8e5290ae4c94bdc1a4c55865be7e1f31dfd857a88b21cbba68b5fea61b/lxml-6.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:8cb26f51c82d77483cdcd2b4a53cda55bbee29b3c2f3ddeb47182a2a9064e4eb", size = 3674431, upload-time = "2025-06-26T16:26:35.959Z" }, - { url = "https://files.pythonhosted.org/packages/79/21/6e7c060822a3c954ff085e5e1b94b4a25757c06529eac91e550f3f5cd8b8/lxml-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6da7cd4f405fd7db56e51e96bff0865b9853ae70df0e6720624049da76bde2da", size = 8414372, upload-time = "2025-06-26T16:26:39.079Z" }, - { url = "https://files.pythonhosted.org/packages/a4/f6/051b1607a459db670fc3a244fa4f06f101a8adf86cda263d1a56b3a4f9d5/lxml-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b34339898bb556a2351a1830f88f751679f343eabf9cf05841c95b165152c9e7", size = 4593940, upload-time = "2025-06-26T16:26:41.891Z" }, - { url = "https://files.pythonhosted.org/packages/8e/74/dd595d92a40bda3c687d70d4487b2c7eff93fd63b568acd64fedd2ba00fe/lxml-6.0.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:51a5e4c61a4541bd1cd3ba74766d0c9b6c12d6a1a4964ef60026832aac8e79b3", size = 5214329, upload-time = "2025-06-26T16:26:44.669Z" }, - { url = "https://files.pythonhosted.org/packages/52/46/3572761efc1bd45fcafb44a63b3b0feeb5b3f0066886821e94b0254f9253/lxml-6.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d18a25b19ca7307045581b18b3ec9ead2b1db5ccd8719c291f0cd0a5cec6cb81", size = 4947559, upload-time = "2025-06-28T18:47:31.091Z" }, - { url = "https://files.pythonhosted.org/packages/94/8a/5e40de920e67c4f2eef9151097deb9b52d86c95762d8ee238134aff2125d/lxml-6.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d4f0c66df4386b75d2ab1e20a489f30dc7fd9a06a896d64980541506086be1f1", size = 5102143, upload-time = "2025-06-28T18:47:33.612Z" }, - { url = "https://files.pythonhosted.org/packages/7c/4b/20555bdd75d57945bdabfbc45fdb1a36a1a0ff9eae4653e951b2b79c9209/lxml-6.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f4b481b6cc3a897adb4279216695150bbe7a44c03daba3c894f49d2037e0a24", size = 5021931, upload-time = "2025-06-26T16:26:47.503Z" }, - { url = "https://files.pythonhosted.org/packages/b6/6e/cf03b412f3763d4ca23b25e70c96a74cfece64cec3addf1c4ec639586b13/lxml-6.0.0-cp313-cp313-manylinux_2_27_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a78d6c9168f5bcb20971bf3329c2b83078611fbe1f807baadc64afc70523b3a", size = 5645469, upload-time = "2025-07-03T19:19:13.32Z" }, - { url = "https://files.pythonhosted.org/packages/d4/dd/39c8507c16db6031f8c1ddf70ed95dbb0a6d466a40002a3522c128aba472/lxml-6.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ae06fbab4f1bb7db4f7c8ca9897dc8db4447d1a2b9bee78474ad403437bcc29", size = 5247467, upload-time = "2025-06-26T16:26:49.998Z" }, - { url = "https://files.pythonhosted.org/packages/4d/56/732d49def0631ad633844cfb2664563c830173a98d5efd9b172e89a4800d/lxml-6.0.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:1fa377b827ca2023244a06554c6e7dc6828a10aaf74ca41965c5d8a4925aebb4", size = 4720601, upload-time = "2025-06-26T16:26:52.564Z" }, - { url = "https://files.pythonhosted.org/packages/8f/7f/6b956fab95fa73462bca25d1ea7fc8274ddf68fb8e60b78d56c03b65278e/lxml-6.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1676b56d48048a62ef77a250428d1f31f610763636e0784ba67a9740823988ca", size = 5060227, upload-time = "2025-06-26T16:26:55.054Z" }, - { url = "https://files.pythonhosted.org/packages/97/06/e851ac2924447e8b15a294855caf3d543424364a143c001014d22c8ca94c/lxml-6.0.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:0e32698462aacc5c1cf6bdfebc9c781821b7e74c79f13e5ffc8bfe27c42b1abf", size = 4790637, upload-time = "2025-06-26T16:26:57.384Z" }, - { url = "https://files.pythonhosted.org/packages/06/d4/fd216f3cd6625022c25b336c7570d11f4a43adbaf0a56106d3d496f727a7/lxml-6.0.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4d6036c3a296707357efb375cfc24bb64cd955b9ec731abf11ebb1e40063949f", size = 5662049, upload-time = "2025-07-03T19:19:16.409Z" }, - { url = "https://files.pythonhosted.org/packages/52/03/0e764ce00b95e008d76b99d432f1807f3574fb2945b496a17807a1645dbd/lxml-6.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7488a43033c958637b1a08cddc9188eb06d3ad36582cebc7d4815980b47e27ef", size = 5272430, upload-time = "2025-06-26T16:27:00.031Z" }, - { url = "https://files.pythonhosted.org/packages/5f/01/d48cc141bc47bc1644d20fe97bbd5e8afb30415ec94f146f2f76d0d9d098/lxml-6.0.0-cp313-cp313-win32.whl", hash = "sha256:5fcd7d3b1d8ecb91445bd71b9c88bdbeae528fefee4f379895becfc72298d181", size = 3612896, upload-time = "2025-06-26T16:27:04.251Z" }, - { url = "https://files.pythonhosted.org/packages/f4/87/6456b9541d186ee7d4cb53bf1b9a0d7f3b1068532676940fdd594ac90865/lxml-6.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:2f34687222b78fff795feeb799a7d44eca2477c3d9d3a46ce17d51a4f383e32e", size = 4013132, upload-time = "2025-06-26T16:27:06.415Z" }, - { url = "https://files.pythonhosted.org/packages/b7/42/85b3aa8f06ca0d24962f8100f001828e1f1f1a38c954c16e71154ed7d53a/lxml-6.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:21db1ec5525780fd07251636eb5f7acb84003e9382c72c18c542a87c416ade03", size = 3672642, upload-time = "2025-06-26T16:27:09.888Z" }, -] - -[[package]] -name = "markdown" -version = "3.8.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/c2/4ab49206c17f75cb08d6311171f2d65798988db4360c4d1485bd0eedd67c/markdown-3.8.2.tar.gz", hash = "sha256:247b9a70dd12e27f67431ce62523e675b866d254f900c4fe75ce3dda62237c45", size = 362071, upload-time = "2025-06-19T17:12:44.483Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/2b/34cc11786bc00d0f04d0f5fdc3a2b1ae0b6239eef72d3d345805f9ad92a1/markdown-3.8.2-py3-none-any.whl", hash = "sha256:5c83764dbd4e00bdd94d85a19b8d55ccca20fe35b2e678a1422b380324dd5f24", size = 106827, upload-time = "2025-06-19T17:12:42.994Z" }, -] - [[package]] name = "markdown-it-py" version = "3.0.0" @@ -158,6 +77,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, ] +[[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" @@ -250,14 +178,14 @@ wheels = [ [[package]] name = "pygments-ashen" -version = "0.1.3" +version = "0.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/a4/e2eeeeb8211b3425eeeeaa49a1d94a3c8bfd6046ce2723897fe778f03198/pygments_ashen-0.1.3.tar.gz", hash = "sha256:2c69a931741f98fca531ef11d17bf690547f14a4382ec0fed7c7715a03aeaaf7", size = 5757, upload-time = "2025-07-04T16:46:51.879Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/11/5246b1ec9a14bbd32970773894ad3ea4a42569e1a72e90f8aff859b43f63/pygments_ashen-0.1.1.tar.gz", hash = "sha256:a39f8277fcfddc7f7c10fc5cfe8695b7fc4ede9776b2d25b2ad8a6b33af1bbd5", size = 5757, upload-time = "2025-06-25T16:59:54.725Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/2d/d9b1b57c4cd394459d6b1456efc6cdaba8c9b8b1fa4d9023c69ad846c7cf/pygments_ashen-0.1.3-py3-none-any.whl", hash = "sha256:c5553a9302d581743d928b834cae8f3f62ee52a7fd8b72b73f04796a1c71cef3", size = 4303, upload-time = "2025-07-04T16:46:49.849Z" }, + { url = "https://files.pythonhosted.org/packages/49/27/f8a48a1a49c422309d6bcd1d2722a19dd90006777d1e972e4b1e128d081c/pygments_ashen-0.1.1-py3-none-any.whl", hash = "sha256:28fd415a38b0168a2134b96a09f34169197229a299ba4d781a8319fc5d7f5e67", size = 4301, upload-time = "2025-06-25T16:59:53.996Z" }, ] [[package]] @@ -272,19 +200,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/35/e8/ccd661508989a0469ee296909f2d6cae8ac24304f9db9afe706f0ef5b8d5/pygments_kakoune-0.1.0-py3-none-any.whl", hash = "sha256:573a933b61c7c7993f52fd02a04b7a936558da2b5a6690728732f2c0a67221a9", size = 3606, upload-time = "2025-06-30T20:27:30.457Z" }, ] -[[package]] -name = "pymdown-extensions" -version = "10.16" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown" }, - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1a/0a/c06b542ac108bfc73200677309cd9188a3a01b127a63f20cadc18d873d88/pymdown_extensions-10.16.tar.gz", hash = "sha256:71dac4fca63fabeffd3eb9038b756161a33ec6e8d230853d3cecf562155ab3de", size = 853197, upload-time = "2025-06-21T17:56:36.974Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/d4/10bb14004d3c792811e05e21b5e5dcae805aacb739bd12a0540967b99592/pymdown_extensions-10.16-py3-none-any.whl", hash = "sha256:f5dd064a4db588cb2d95229fc4ee63a1b16cc8b4d0e6145c0899ed8723da1df2", size = 266143, upload-time = "2025-06-21T17:56:35.356Z" }, -] - [[package]] name = "pytest" version = "8.4.1" @@ -301,18 +216,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, ] -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, -] - [[package]] name = "python-frontmatter" version = "1.1.0" @@ -398,15 +301,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - [[package]] name = "typer" version = "0.16.0" @@ -476,57 +370,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] -[[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 = "1.2.1" +version = "0.1.0" source = { editable = "." } dependencies = [ { name = "dacite" }, - { name = "feedgen" }, { name = "jinja2" }, - { name = "l2m4m" }, - { name = "markdown" }, + { name = "marko" }, { name = "pygments" }, { name = "pygments-ashen" }, { name = "pygments-kakoune" }, - { name = "pymdown-extensions" }, - { name = "python-dateutil" }, { name = "python-frontmatter" }, { name = "rich" }, { name = "typer" }, { name = "watchdog" }, - { name = "websockets" }, ] [package.dev-dependencies] @@ -540,20 +398,15 @@ 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-ashen", specifier = ">=0.1.0" }, { name = "pygments-kakoune", specifier = ">=0.1.0" }, - { name = "pymdown-extensions", specifier = ">=10.16" }, - { name = "python-dateutil", specifier = ">=2.9.0.post0" }, { name = "python-frontmatter", specifier = ">=1.1.0" }, { name = "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]