From db8d12991d200fb466a4f256f57d23c627a69142 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sat, 5 Jul 2025 02:34:33 -0400 Subject: [PATCH 01/76] removed marko dependency --- pyproject.toml | 1 - uv.lock | 11 ----------- 2 files changed, 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ec3a6f3..83d80be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,6 @@ dependencies = [ "dacite>=1.9.2", "jinja2>=3.1.6", "markdown>=3.8.2", - "marko>=2.1.4", "pygments>=2.19.1", "pygments-ashen>=0.1.3", "pygments-kakoune>=0.1.0", diff --git a/uv.lock b/uv.lock index 3a0a146..72489e8 100644 --- a/uv.lock +++ b/uv.lock @@ -86,15 +86,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, ] -[[package]] -name = "marko" -version = "2.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/dc/c8cadbd83de1b38d95a48568b445a5553005ebdd32e00a333ca940113db4/marko-2.1.4.tar.gz", hash = "sha256:dd7d66f3706732bf8f994790e674649a4fd0a6c67f16b80246f30de8e16a1eac", size = 142795, upload-time = "2025-06-13T03:25:50.857Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/66/49e3691d14898fb6e34ccb337c7677dfb7e18269ed170f12e4b85315eae6/marko-2.1.4-py3-none-any.whl", hash = "sha256:81c2b9f570ca485bc356678d9ba1a1b3eb78b4a315d01f3ded25442fdc796990", size = 42186, upload-time = "2025-06-13T03:25:49.858Z" }, -] - [[package]] name = "markupsafe" version = "3.0.2" @@ -421,7 +412,6 @@ dependencies = [ { name = "dacite" }, { name = "jinja2" }, { name = "markdown" }, - { name = "marko" }, { name = "pygments" }, { name = "pygments-ashen" }, { name = "pygments-kakoune" }, @@ -446,7 +436,6 @@ requires-dist = [ { name = "dacite", specifier = ">=1.9.2" }, { name = "jinja2", specifier = ">=3.1.6" }, { name = "markdown", specifier = ">=3.8.2" }, - { name = "marko", specifier = ">=2.1.4" }, { name = "pygments", specifier = ">=2.19.1" }, { name = "pygments-ashen", specifier = ">=0.1.3" }, { name = "pygments-kakoune", specifier = ">=0.1.0" }, From da4e2620e930a721dfbbe93b95708f60996c3fec Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sat, 5 Jul 2025 02:48:10 -0400 Subject: [PATCH 02/76] update readme --- README.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 673073b..9ad13df 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ releases yet. For an example of a website built with Zona, please see [ficd.ca](https://ficd.ca) + - [Features](#features) - [Internal Link Resolution](#internal-link-resolution) - [Syntax Highlighting](#syntax-highlighting) @@ -19,6 +20,7 @@ releases yet. For an example of a website built with Zona, please see - [Site Layout](#site-layout) - [Frontmatter](#frontmatter) - [Configuration](#configuration) + ## Features @@ -54,7 +56,7 @@ including those pointing to static resources like images. ### Syntax Highlighting Zona uses [Pygments] to provide syntax highlighting for fenced code blocks. The -following plugins are included: +following Pygments plugins are included: - [pygments-kakoune](https://git.sr.ht/~ficd/pygments-kakoune) - A lexer providing for highlighting Kakoune code. Available under the `kak` @@ -63,6 +65,23 @@ following plugins are included: - An implementation of the [Ashen](https://git.sr.ht/~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 +``` + + ### Image Labels A feature unique to Zona is **image labels**. They make it easy to annotate From fc8897b71b91893df4ed86fcdc170664b830451a Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sat, 5 Jul 2025 15:45:44 -0400 Subject: [PATCH 03/76] added table support to default stylesheet --- src/zona/data/content/static/style.css | 47 ++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/src/zona/data/content/static/style.css b/src/zona/data/content/static/style.css index 675056d..0ebd3fb 100644 --- a/src/zona/data/content/static/style.css +++ b/src/zona/data/content/static/style.css @@ -133,7 +133,7 @@ pre { } pre { - background-color: #1d1d1d; + background-color: #151515; color: #d5d5d5; padding: 1em; border-radius: 5px; @@ -241,7 +241,48 @@ small a { #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: 'Fira Code', 'Consolas', 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); +} From 99ad674f5132580addb7b4ae6f037764f4ed4a91 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sat, 5 Jul 2025 16:01:15 -0400 Subject: [PATCH 04/76] added show_title frontmatter option --- src/zona/data/templates/basic.html | 4 ++++ src/zona/data/templates/page.html | 3 +++ src/zona/metadata.py | 1 + 3 files changed, 8 insertions(+) diff --git a/src/zona/data/templates/basic.html b/src/zona/data/templates/basic.html index fbedadb..033d9e3 100644 --- a/src/zona/data/templates/basic.html +++ b/src/zona/data/templates/basic.html @@ -1,6 +1,10 @@ {% extends "base.html" %} {% block content %} +{% if metadata.show_title %} +

{{ metadata.title }}

+{% endif %} {{ content | safe }} {% endblock %} + diff --git a/src/zona/data/templates/page.html b/src/zona/data/templates/page.html index 91b2616..3e835b4 100644 --- a/src/zona/data/templates/page.html +++ b/src/zona/data/templates/page.html @@ -1,10 +1,13 @@ {% extends "base.html" %} {% block content %} +{% if metadata.show_title %}

{{ metadata.title }}

+{% endif %} {% if metadata.date %}
{% endif %}
{{ content | safe }}
{% endblock %} + diff --git a/src/zona/metadata.py b/src/zona/metadata.py index eaa6315..230ca2a 100644 --- a/src/zona/metadata.py +++ b/src/zona/metadata.py @@ -16,6 +16,7 @@ class Metadata: title: str date: date description: str | None + show_title: bool = True style: str | None = "/static/style.css" header: bool = True footer: bool = True From 85fa619828c1bedda4c8c46a1287b6d1fae0bccb Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sat, 5 Jul 2025 16:14:20 -0400 Subject: [PATCH 05/76] added markdown.tab_length config option --- src/zona/config.py | 1 + src/zona/markdown.py | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/zona/config.py b/src/zona/config.py index b735c85..2a18f91 100644 --- a/src/zona/config.py +++ b/src/zona/config.py @@ -38,6 +38,7 @@ class HighlightingConfig: @dataclass class MarkdownConfig: image_labels: bool = True + tab_length: int = 2 syntax_highlighting: HighlightingConfig = field( default_factory=HighlightingConfig ) diff --git a/src/zona/markdown.py b/src/zona/markdown.py index 1a72804..956db70 100644 --- a/src/zona/markdown.py +++ b/src/zona/markdown.py @@ -144,15 +144,20 @@ def md_to_html( MarkdownInHtmlExtension(), EscapeAllExtension(hardbreak=True), ] + kwargs: dict[str, Any] = { + "extensions": extensions, + "tab_length": 2, + } if config: - extensions.append( + kwargs["extensions"].append( CodeHiliteExtension( linenums=False, noclasses=False, pygments_style=config.markdown.syntax_highlighting.theme, ) ) - md = Markdown(extensions=extensions) + kwargs["tab_length"] = config.markdown.tab_length + md = Markdown(**kwargs) if resolve_links: if source is None or layout is None or item_map is None: raise TypeError( From 47be4986a50d0e921c1f2afa90fb9d029a5c2c85 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sat, 5 Jul 2025 16:34:47 -0400 Subject: [PATCH 06/76] added markdown extensions, disables indented code blocks --- src/zona/markdown.py | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/src/zona/markdown.py b/src/zona/markdown.py index 956db70..9e5341c 100644 --- a/src/zona/markdown.py +++ b/src/zona/markdown.py @@ -10,12 +10,19 @@ from zona.layout import Layout from markdown.treeprocessors import Treeprocessor from markdown.extensions.codehilite import CodeHiliteExtension -from markdown.extensions.extra import ExtraExtension from markdown.extensions.smarty import SmartyExtension from markdown.extensions.sane_lists import SaneListExtension -from markdown.extensions.md_in_html import MarkdownInHtmlExtension from pymdownx.inlinehilite import InlineHiliteExtension from pymdownx.escapeall import EscapeAllExtension +from pymdownx.betterem import BetterEmExtension +from pymdownx.superfences import SuperFencesCodeExtension +from markdown.extensions.footnotes import FootnoteExtension +from markdown.extensions.attr_list import AttrListExtension +from markdown.extensions.def_list import DefListExtension +from markdown.extensions.tables import TableExtension +from markdown.extensions.abbr import AbbrExtension +from markdown.extensions.md_in_html import MarkdownInHtmlExtension + import xml.etree.ElementTree as etree from zona import util @@ -134,12 +141,20 @@ def md_to_html( item_map: dict[Path, Item] | None = None, ) -> str: extensions: Sequence[Any] = [ - ExtraExtension(), + BetterEmExtension(), + SuperFencesCodeExtension( + disable_indented_code_blocks=True, + css_class="codehilite", + ), + FootnoteExtension(), + AttrListExtension(), + DefListExtension(), + TableExtension(), + AbbrExtension(), SmartyExtension(), "pymdownx.tilde", "pymdownx.caret", "pymdownx.smartsymbols", - InlineHiliteExtension(css_class="codehilite"), SaneListExtension(), MarkdownInHtmlExtension(), EscapeAllExtension(hardbreak=True), @@ -149,12 +164,15 @@ def md_to_html( "tab_length": 2, } if config: - kwargs["extensions"].append( - CodeHiliteExtension( - linenums=False, - noclasses=False, - pygments_style=config.markdown.syntax_highlighting.theme, - ) + kwargs["extensions"].extend( + [ + CodeHiliteExtension( + linenums=False, + noclasses=False, + pygments_style=config.markdown.syntax_highlighting.theme, + ), + InlineHiliteExtension(css_class="codehilite"), + ] ) kwargs["tab_length"] = config.markdown.tab_length md = Markdown(**kwargs) From 7c52f28aa798f7749dcd7ae1f0834355f2f52cde Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sat, 5 Jul 2025 17:40:52 -0400 Subject: [PATCH 07/76] updated default ignorelist --- src/zona/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zona/config.py b/src/zona/config.py index 2a18f91..5caf860 100644 --- a/src/zona/config.py +++ b/src/zona/config.py @@ -50,7 +50,7 @@ class BuildConfig: include_drafts: bool = False -IGNORELIST = [".git", ".env", "*/.marksman.toml"] +IGNORELIST = [".marksman.toml"] @dataclass From 08e51665e0ce36b11ad30a9242f52bd907e2443d Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sat, 5 Jul 2025 17:44:48 -0400 Subject: [PATCH 08/76] updated documentation --- README.md | 302 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 231 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index 9ad13df..d66c2c2 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,38 @@ -

Zona

+

zona

-[Zona](https://sr.ht/~ficd/zona) is an opinionated static site generator written -in Python. From a structured directory of Markdown content, Zona builds a simple -static website. It's designed to get out of your way and let you focus on -writing. +[zona](https://sr.ht/~ficd/zona) is an _opinionated_ static site generator +written in Python. From a structured directory of Markdown content, zona builds +a simple static website. It's designed to get out of your way and let you focus +on writing. -**Note:** This project is in early development, and there are no versioned -releases yet. For an example of a website built with Zona, please see -[ficd.ca](https://ficd.ca) +**What do I mean by opinionated?** I built zona primarily for myself. I've tried +making it flexible by exposing 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. + +**Note:** This project is in early development, there are no versioned releases +yet, and breaking changes are likely. Versioned releases will be made and zona +will be published to PyPI once it's stable. + +For an example of a website built with zona, please see +[ficd.ca](https://ficd.ca). - - [Features](#features) +- [Installation](#installation) +- [Usage](#usage) + - [Getting Started](#getting-started) + - [Site Layout](#site-layout) - [Internal Link Resolution](#internal-link-resolution) - [Syntax Highlighting](#syntax-highlighting) - [Image Labels](#image-labels) -- [Installation](#installation) -- [Usage](#usage) - - [Site Layout](#site-layout) - [Frontmatter](#frontmatter) - - [Configuration](#configuration) - + - [Post List](#post-list) +- [Configuration](#configuration) + - [Sitemap](#sitemap) + - [Ignore List](#ignore-list) + - [Drafts](#drafts) ## Features @@ -33,16 +45,78 @@ releases yet. For an example of a website built with Zona, please see - Easily configurable sitemap header. - Site footer written in Markdown. - Smart site layout discovery. - - Blog posts automatically discovered and rendered accordingly (can be + - Blog posts are automatically discovered and rendered accordingly (can be overridden in frontmatter). - Extended Markdown renderer: - Smart internal link resolution. - Syntax highlighting. + - Includes Kakoune syntax and [Ashen] highlighting. - [Image labels](#image-labels). + - Many `python-markdown` extensions enabled, including footnotes, tables, + abbreviations, etc. + +## Installation + +zona is not yet packaged on PyPI. You may use `uv` to install it from this +repository: + +```sh +uv tool install 'git+https://git.sr.ht/~ficd/zona' +``` + +## Usage + +_Note: you may provide the `--help` option to any subcommand to see the +available options and arguments._ + +### 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. + +To build the website, run `zona build`. The project root is discovered according +to the location of `config.yml`. By default, the output directory is called +`public`, and saved inside the root directory. + +To start a live preview session, execute `zona serve`. The server will run until +it's killed by the user, and the website is rebuilt if any source files are +modified. _Note: if you change `config.yml` or any templates, you will need to +restart the preview server_. + +### 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 prioritized if it exists. `public` is the built site output — +it's recommended to add this path to your `.gitignore`. + +The `content` directory is the **root of the website**. 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. ### Internal Link Resolution -When Zona encounters links in Markdown documents, it attempts to resolve them as +When zona encounters links in Markdown documents, it attempts to resolve them as internal links. Links beginning with `/` are resolved relative to the content root; otherwise, they are resolved relative to the Markdown file. If the link resolves to an existing file that is part of the website, it's replaced with an @@ -51,11 +125,12 @@ appropriate web-server-friendly link. Otherwise, the link isn't changed. For example, suppose the file `blog/post1.md` has a link `./post2.md`. The HTML output will contain the link `/blog/post2` (which corresponds to `/blog/post2/index.html`). Link resolution is applied to _all_ internal links, -including those pointing to static resources like images. +including those pointing to static resources like images. Links are only +modified if they point to a real file that's not included in the ignore list. ### Syntax Highlighting -Zona uses [Pygments] to provide syntax highlighting for fenced code blocks. The +zona uses [Pygments] to provide syntax highlighting for fenced code blocks. The following Pygments plugins are included: - [pygments-kakoune](https://git.sr.ht/~ficd/pygments-kakoune) @@ -65,8 +140,9 @@ following Pygments plugins are included: - An implementation of the [Ashen](https://git.sr.ht/~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): +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 @@ -75,16 +151,26 @@ markdown: theme: catppucin-mocha ``` -Then, run Zona with the following `uv` command: +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) +``` ### Image Labels -A feature unique to Zona is **image labels**. They make it easy to annotate +A feature unique to zona is **image labels**. They make it easy to annotate images in your Markdown documents. The alt text Markdown element is rendered as the label — with support for inline Markdown. Consider this example: @@ -101,57 +187,131 @@ The above results in the following HTML: ``` The `image-container` class is provided as a convenience for styling. The -default stylesheet centers the label under the image. - -## Installation - -Zona is not yet packaged on PyPI. You may use `uv` to install it from this -repository: - -```sh -uv tool install 'git+https://git.sr.ht/~ficd/zona' -``` - -## Usage - -_Note: you may provide the `--help` option to any subcommand to see the -available options and arguments. - -To set up a new website, create a new directory and run `zona init` inside of -it. This creates the required directory structure and writes the default -configuration file. The default templates and default stylesheet are also -written. - -To build the website, run `zona build`. The project root is discovered according -to the location of `config.yml`. By default, the output directory is called -`public`, and saved inside the root directory. - -To start a live preview session, execute `zona serve`. The server will run until -it's killed by the user, and the website is rebuilt if any source files are -modified. - -### Site Layout - -The **root** of the **Zona project** _must_ contain the configuration file, -`config.yml`, and a directory called `content`. A directory called `templates` -is optional, and prioritized if it exists. `public` is the built site output — -it's recommended to add this path to your `.gitignore`. - -The `content` directory is the root of the website. For example, suppose your -website is hosted at `example.com`. `content/blog/index.md` corresponds to -`example.com/blog`, `content/blog/my-post.md` becomes -`example.com/blog/my-post`, etc. Internal links are resolved **relative to the -`content` directory.** - -Markdown files inside a certain directory (`content/blog` by default) are -automatically treated as _blog posts_. This means they are rendered with the -`page.html` template, and included in the `post_list`, which can be included in -your site using the `post_list.html` template. +default stylesheet centers the label under the image. Note: _links_ inside image +captions are not currently supported. I am looking into a solution. ### Frontmatter -WIP +YAML frontmatter can be used to configure the metadata of documents. All of them +are optional. `none` is used when the option is unset. The following options are +available: -### Configuration +| Key | Type & Default | Description | +| ------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `title` | `str` = title-cased filename. | Title of the page. | +| `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. | -WIP +**Note**: you can specify the date in any format that can be parsed by +[`python-dateutil`](https://pypi.org/project/python-dateutil/). + +### Post List + +Suppose you want `example.com/blog` to be a _post list_ page, and you want +`example.com/blog/my-post` to be a post. You would first create +`content/blog/index.md` and add the following frontmatter: + +```markdown +--- +title: Blog +post: false +template: post_list + +Welcome to my blog! Please find a list of my posts below. +--- +``` + +Setting `post: false` is necessary because, by default, all documents inside +`content/blog` are considered to be posts unless explicitly disabled in the +frontmatter. We don't want the post list to list _itself_ as a post. + +Then, you'd create `content/blog/my-post.md` and populate it: + +```markdown +--- +title: My First Post +date: July 5, 2025 +--- +``` + +Because `my-post` is inside the `blog` directory, `post: true` is implied. If +you wanted to put it somewhere outside `blog`, you would need to set +`post: true` for it to be included in the post list. + +## Configuration + +Zona is configured in YAML format. The configuration file is called `config.yml` +and it **must** be located in the root of the project — in the same directory as +`content` and `templates`. + +Your configuration will be merged with the defaults. `zona init` also writes a +copy of the default configuration to the correct location. If it exists, you'll +be prompted before overwriting it. + +**Note:** Currently, not every configuration value is actually used. Only the +useful settings are listed here. + +Please see the default configuration: + +```yaml +sitemap: + Home: / +ignore: + - .marksman.toml +markdown: + image_labels: true + tab_length: 2 + syntax_highlighting: + enabled: true + theme: ashen + wrap: false +blog: + dir: blog +``` + +| Name | Description | +| -------------------------------------- | --------------------------------------------------------------------------------------------- | +| `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. | +| `blog.dir` | Name of a directory relative to `content/` whose children are automatically considered posts. | + +### 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.sr.ht/~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://sr.ht/~ficd/ashen +[Pygments]: https://pygments.org/ From 2c7fcfc05b9c1aae462aac0095911638c55ba106 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sat, 5 Jul 2025 17:46:43 -0400 Subject: [PATCH 09/76] added note to readme --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d66c2c2..a885bd9 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,16 @@ are comfortable with modifying `jinja2` templates and CSS, then you're in luck. **Note:** This project is in early development, there are no versioned releases yet, and breaking changes are likely. Versioned releases will be made and zona -will be published to PyPI once it's stable. +will be published to PyPI once it's stable. zona was previously implemented in +Go; I decided to rewrite the project in Python. If you're interested in seeing +the previous codebase (which is feature incomplete), visit the +[~ficd/zona-go](https://git.sr.ht/~ficd/zona-go) repository. For an example of a website built with zona, please see [ficd.ca](https://ficd.ca). + - [Features](#features) - [Installation](#installation) - [Usage](#usage) @@ -33,6 +37,7 @@ For an example of a website built with zona, please see - [Sitemap](#sitemap) - [Ignore List](#ignore-list) - [Drafts](#drafts) + ## Features From d8ca92dce65bd600c6c5e2028cd3ca14dc2f279f Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sat, 5 Jul 2025 17:58:20 -0400 Subject: [PATCH 10/76] fixed mistakes in readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a885bd9..90abd06 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ a simple static website. It's designed to get out of your way and let you focus on writing. **What do I mean by opinionated?** I built zona primarily for myself. I've tried -making it flexible by exposing many variables as possible to the template +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. @@ -226,9 +226,9 @@ Suppose you want `example.com/blog` to be a _post list_ page, and you want 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 From 1221f43cafc214cfd8aade351a6bd7371211cfb7 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sun, 6 Jul 2025 00:35:04 -0400 Subject: [PATCH 11/76] formatted imports --- src/zona/builder.py | 38 ++++++++++++++++++++------------- src/zona/cli.py | 12 +++++++---- src/zona/config.py | 10 ++++++--- src/zona/layout.py | 17 ++++++++++----- src/zona/log.py | 2 +- src/zona/markdown.py | 49 ++++++++++++++++++++----------------------- src/zona/metadata.py | 7 ++++--- src/zona/models.py | 4 ++-- src/zona/server.py | 19 +++++++++-------- src/zona/templates.py | 4 +++- src/zona/util.py | 19 ++++++++++------- tests/test_builder.py | 8 ++++--- tests/test_util.py | 1 + 13 files changed, 110 insertions(+), 80 deletions(-) diff --git a/src/zona/builder.py b/src/zona/builder.py index ac1a5e7..8f7a3dc 100644 --- a/src/zona/builder.py +++ b/src/zona/builder.py @@ -1,14 +1,14 @@ from datetime import date -from zona.models import Item, ItemType -from zona.metadata import parse_metadata -from zona import markdown as zmd -from zona.templates import Templater -from zona.layout import Layout, discover_layout -from zona.config import ZonaConfig -from zona import util from pathlib import Path -from rich import print + +from zona import markdown as zmd +from zona import util +from zona.config import ZonaConfig +from zona.layout import Layout, discover_layout from zona.log import get_logger +from zona.metadata import parse_metadata +from zona.models import Item, ItemType +from zona.templates import Templater logger = get_logger() @@ -47,7 +47,9 @@ 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( @@ -59,7 +61,7 @@ class ZonaBuilder: and not self.config.build.include_drafts ): continue - if item.metadata.post == True: + if item.metadata.post: item.post = True elif item.metadata.post is None: # check if in posts dir? @@ -70,11 +72,13 @@ class ZonaBuilder: item.copy = False name = destination.stem if name == "index": - item.destination = item.destination.with_suffix( - ".html" + item.destination = ( + item.destination.with_suffix(".html") ) else: - relative = path.relative_to(base).with_suffix("") + relative = path.relative_to(base).with_suffix( + "" + ) name = relative.stem item.destination = ( layout.output @@ -86,7 +90,9 @@ class ZonaBuilder: layout.output ) item.url = ( - "" if rel_url == Path(".") else rel_url.as_posix() + "" + if rel_url == Path(".") + else rel_url.as_posix() ) items.append(item) self.items = items @@ -112,7 +118,9 @@ class ZonaBuilder: # write code highlighting stylesheet if self.config.markdown.syntax_highlighting.enabled: pygments_style = zmd.get_style_defs(self.config) - pygments_path = self.layout.output / "static" / "pygments.css" + pygments_path = ( + self.layout.output / "static" / "pygments.css" + ) util.ensure_parents(pygments_path) pygments_path.write_text(pygments_style) for item in self.item_map.values(): diff --git a/src/zona/cli.py b/src/zona/cli.py index 8ecaaf1..e928273 100644 --- a/src/zona/cli.py +++ b/src/zona/cli.py @@ -1,10 +1,12 @@ -from typing import Annotated -import typer from pathlib import Path +from typing import Annotated + +import typer + from zona import server from zona.builder import ZonaBuilder from zona.layout import initialize_site -from zona.log import setup_logging, get_logger +from zona.log import get_logger, setup_logging app = typer.Typer() logger = get_logger() @@ -57,7 +59,9 @@ def build( """ if draft: print("Option override: including drafts.") - builder = ZonaBuilder(cli_root=root, cli_output=output, draft=draft) + builder = ZonaBuilder( + cli_root=root, cli_output=output, draft=draft + ) builder.build() diff --git a/src/zona/config.py b/src/zona/config.py index 5caf860..62b2bef 100644 --- a/src/zona/config.py +++ b/src/zona/config.py @@ -1,7 +1,9 @@ from dataclasses import dataclass, field -from dacite import from_dict -import yaml from pathlib import Path + +import yaml +from dacite import from_dict + from zona.log import get_logger logger = get_logger() @@ -57,7 +59,9 @@ IGNORELIST = [".marksman.toml"] class ZonaConfig: base_url: str = "/" # dictionary where key is name, value is url - sitemap: SitemapConfig = field(default_factory=lambda: {"Home": "/"}) + sitemap: SitemapConfig = field( + default_factory=lambda: {"Home": "/"} + ) # list of globs relative to content that should be ignored ignore: list[str] = field(default_factory=lambda: IGNORELIST) markdown: MarkdownConfig = field(default_factory=MarkdownConfig) diff --git a/src/zona/layout.py b/src/zona/layout.py index 688a902..3526c4e 100644 --- a/src/zona/layout.py +++ b/src/zona/layout.py @@ -1,10 +1,12 @@ +from dataclasses import asdict, dataclass from pathlib import Path + import typer -from dataclasses import dataclass, asdict -from zona.config import ZonaConfig, find_config -from zona import util, log import yaml +from zona import log, util +from zona.config import ZonaConfig, find_config + logger = log.get_logger() @@ -17,13 +19,18 @@ class Layout: @classmethod def from_input( - cls, root: Path, output: Path | None = None, validate: bool = True + cls, + root: Path, + output: Path | None = None, + validate: bool = True, ) -> "Layout": layout = cls( root=root.resolve(), content=(root / "content").resolve(), templates=(root / "templates").resolve(), - output=(root / "public").resolve() if not output else output, + output=(root / "public").resolve() + if not output + else output, ) if validate: logger.debug("Validating site layout...") diff --git a/src/zona/log.py b/src/zona/log.py index 129a36c..545ec49 100644 --- a/src/zona/log.py +++ b/src/zona/log.py @@ -1,5 +1,5 @@ import logging -from typing import Literal + from rich.logging import RichHandler _LOGGER_NAME = "zona" diff --git a/src/zona/markdown.py b/src/zona/markdown.py index 9e5341c..b829be6 100644 --- a/src/zona/markdown.py +++ b/src/zona/markdown.py @@ -1,33 +1,30 @@ -from collections.abc import Sequence -from rich import print -from markdown import Markdown -from typing import Any, override -from pathlib import Path - -from pygments.formatters.html import HtmlFormatter -from zona.config import ZonaConfig -from zona.layout import Layout - -from markdown.treeprocessors import Treeprocessor -from markdown.extensions.codehilite import CodeHiliteExtension -from markdown.extensions.smarty import SmartyExtension -from markdown.extensions.sane_lists import SaneListExtension -from pymdownx.inlinehilite import InlineHiliteExtension -from pymdownx.escapeall import EscapeAllExtension -from pymdownx.betterem import BetterEmExtension -from pymdownx.superfences import SuperFencesCodeExtension -from markdown.extensions.footnotes import FootnoteExtension -from markdown.extensions.attr_list import AttrListExtension -from markdown.extensions.def_list import DefListExtension -from markdown.extensions.tables import TableExtension -from markdown.extensions.abbr import AbbrExtension -from markdown.extensions.md_in_html import MarkdownInHtmlExtension - import xml.etree.ElementTree as etree +from collections.abc import Sequence +from pathlib import Path +from typing import Any, override + +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.treeprocessors import Treeprocessor +from pygments.formatters.html import HtmlFormatter +from pymdownx.betterem import BetterEmExtension +from pymdownx.escapeall import EscapeAllExtension +from pymdownx.inlinehilite import InlineHiliteExtension +from pymdownx.superfences import SuperFencesCodeExtension from zona import util -from zona.models import Item +from zona.config import ZonaConfig +from zona.layout import Layout from zona.log import get_logger +from zona.models import Item logger = get_logger() diff --git a/src/zona/metadata.py b/src/zona/metadata.py index 230ca2a..63fc43f 100644 --- a/src/zona/metadata.py +++ b/src/zona/metadata.py @@ -1,14 +1,15 @@ from dataclasses import dataclass -from pathlib import Path from datetime import date -from dateutil import parser as date_parser +from pathlib import Path +import frontmatter from dacite.config import Config from dacite.core import from_dict from dacite.exceptions import DaciteError +from dateutil import parser as date_parser from yaml import YAMLError + import zona.util -import frontmatter @dataclass diff --git a/src/zona/models.py b/src/zona/models.py index 785f02f..2e7721c 100644 --- a/src/zona/models.py +++ b/src/zona/models.py @@ -1,6 +1,6 @@ -from pathlib import Path -from enum import Enum from dataclasses import dataclass +from enum import Enum +from pathlib import Path from zona.metadata import Metadata diff --git a/src/zona/server.py b/src/zona/server.py index e2b266e..dbf5c82 100644 --- a/src/zona/server.py +++ b/src/zona/server.py @@ -1,15 +1,16 @@ -import signal import os +import signal import sys -from rich import print -from types import FrameType -from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler import threading -from typing import override -from watchdog.observers import Observer -from zona.builder import ZonaBuilder -from watchdog.events import FileSystemEventHandler, FileSystemEvent +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +from types import FrameType +from typing import override + +from watchdog.events import FileSystemEvent, FileSystemEventHandler +from watchdog.observers import Observer + +from zona.builder import ZonaBuilder from zona.log import get_logger logger = get_logger() @@ -64,7 +65,7 @@ def run_http_server(dir: Path, host: str = "localhost", port: int = 8000): server_address=(host, port), RequestHandlerClass=handler ) logger.info(f"Serving {dir} at http://{host}:{port}") - logger.info(f"Exit with ") + logger.info("Exit with ") httpd.serve_forever() diff --git a/src/zona/templates.py b/src/zona/templates.py index ea7df47..872762c 100644 --- a/src/zona/templates.py +++ b/src/zona/templates.py @@ -1,9 +1,11 @@ from pathlib import Path from typing import Literal + from jinja2 import Environment, FileSystemLoader, select_autoescape + from zona.config import ZonaConfig -from zona.models import Item from zona.markdown import md_to_html +from zona.models import Item def get_header(template_dir: Path) -> str | None: diff --git a/src/zona/util.py b/src/zona/util.py index c3caaed..7e80500 100644 --- a/src/zona/util.py +++ b/src/zona/util.py @@ -1,11 +1,10 @@ -from importlib.resources.abc import Traversable -from typing import NamedTuple -from rich import print -from importlib import resources import fnmatch +import string +from importlib import resources +from importlib.resources.abc import Traversable from pathlib import Path from shutil import copy2 -import string +from typing import NamedTuple class ZonaResource(NamedTuple): @@ -26,7 +25,8 @@ def get_resources(subdir: str) -> list[ZonaResource]: else: out.append( ZonaResource( - name=f"{subdir}/{path}", contents=item.read_text() + name=f"{subdir}/{path}", + contents=item.read_text(), ) ) @@ -65,8 +65,11 @@ def normalize_url(url: str) -> str: return url -def should_ignore(path: Path, patterns: list[str], base: Path) -> bool: +def should_ignore( + path: Path, patterns: list[str], base: Path +) -> bool: rel_path = path.relative_to(base) return any( - fnmatch.fnmatch(str(rel_path), pattern) for pattern in patterns + fnmatch.fnmatch(str(rel_path), pattern) + for pattern in patterns ) diff --git a/tests/test_builder.py b/tests/test_builder.py index 61e148e..bd0c9b7 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -1,9 +1,11 @@ -import pytest from datetime import date -from zona.metadata import Metadata -from zona.builder import split_metadata, discover, build from pathlib import Path +import pytest + +from zona.builder import build, discover, split_metadata +from zona.metadata import Metadata + def test_split_metadata(tmp_path: Path): content = """--- diff --git a/tests/test_util.py b/tests/test_util.py index 2837df4..2c1a361 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,4 +1,5 @@ from pathlib import Path + from zona import util From ecd3e50218db30e3bb2d69b8716cb27bd8d33949 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sun, 6 Jul 2025 14:47:24 -0400 Subject: [PATCH 12/76] implemented reload script injection --- src/zona/server.py | 165 ++++++++++++++++++++++++++++++++++++----- src/zona/websockets.py | 52 +++++++++++++ 2 files changed, 197 insertions(+), 20 deletions(-) create mode 100644 src/zona/websockets.py diff --git a/src/zona/server.py b/src/zona/server.py index dbf5c82..2e2683a 100644 --- a/src/zona/server.py +++ b/src/zona/server.py @@ -1,3 +1,4 @@ +import io import os import signal import sys @@ -7,22 +8,110 @@ from pathlib import Path from types import FrameType from typing import override +from rich import print from watchdog.events import FileSystemEvent, FileSystemEventHandler from watchdog.observers import Observer from zona.builder import ZonaBuilder from zona.log import get_logger +from zona.websockets import WebSocketServer logger = get_logger() +def make_reload_script(host: str, port: int) -> str: + """Generates the JavaScript that must be injected into HTML pages for the live reloading to work.""" + 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(path).suffix in {".html", ".htm"} and self.script != "": + try: + logger.debug("Injecting reload script") + # 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] @@ -33,12 +122,33 @@ class ZonaServer(ThreadingHTTPServer): class ZonaReloadHandler(FileSystemEventHandler): - def __init__(self, builder: ZonaBuilder, output: Path): + """FileSystemEventHandler that rebuilds the website + and triggers the browser into refreshing over WebSocket.""" + + def __init__( + self, + builder: ZonaBuilder, + output: Path, + ws_server: WebSocketServer, + ): self.builder: ZonaBuilder = builder self.output: Path = output.resolve() + self.ws_server: WebSocketServer = 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() + assert 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 @@ -47,26 +157,11 @@ class ZonaReloadHandler(FileSystemEventHandler): @override def on_modified(self, event: FileSystemEvent): - if not self._should_ignore(event): - logger.info(f"Modified: {event.src_path}, rebuilding...") - self.builder.build() + self._trigger_rebuild(event) @override def on_created(self, event: FileSystemEvent): - if not self._should_ignore(event): - logger.info(f"Modified: {event.src_path}, rebuilding...") - self.builder.build() - - -def run_http_server(dir: Path, host: str = "localhost", port: int = 8000): - os.chdir(dir) - handler = QuietHandler - httpd = ZonaServer( - server_address=(host, port), RequestHandlerClass=handler - ) - logger.info(f"Serving {dir} at http://{host}:{port}") - logger.info("Exit with ") - httpd.serve_forever() + self._trigger_rebuild(event) def serve( @@ -76,28 +171,58 @@ def serve( host: str = "localhost", port: int = 8000, ): + """Serve preview website with live reload and automatic rebuild.""" builder = ZonaBuilder(root, output, draft) + # 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 + # 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 + reload_script = make_reload_script(host, ws_port) + # serve the output directory + os.chdir(output) + # generate handler with reload script as attribute + handler = make_handler_class(reload_script) + # initialize http server + httpd = ZonaServer( + server_address=(host, port), RequestHandlerClass=handler + ) + # link websocket server + httpd.set_ws_server(ws_server) + # provide link to user + print(f"Serving {output} at http://{host}:{port}") + print("Exit with ") + + # start server in a thread server_thread = threading.Thread( - target=run_http_server, args=(output, host, port), daemon=True + target=httpd.serve_forever, daemon=True ) server_thread.start() - event_handler = ZonaReloadHandler(builder, output) + # initialize reload handler + event_handler = ZonaReloadHandler(builder, output, ws_server) observer = Observer() observer.schedule(event_handler, path=str(root), recursive=True) observer.start() + # function to shut down gracefully def shutdown_handler(_a: int, _b: FrameType | None): logger.info("Shutting down...") observer.stop() + httpd.shutdown() + sys.exit(0) + # register shutdown handler signal.signal(signal.SIGINT, shutdown_handler) signal.signal(signal.SIGTERM, shutdown_handler) + # start file change watcher observer.join() diff --git a/src/zona/websockets.py b/src/zona/websockets.py new file mode 100644 index 0000000..1c32494 --- /dev/null +++ b/src/zona/websockets.py @@ -0,0 +1,52 @@ +import asyncio +from threading import Thread + +from websockets.legacy.server import WebSocketServerProtocol, serve + + +class WebSocketServer: + 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): + self.clients.add(ws) + try: + await ws.wait_closed() + finally: + self.clients.remove(ws) + + def start(self): + def run(): + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + ws_server = serve( + ws_handler=self._handler, host=self.host, port=self.port + ) + self.loop.run_until_complete(ws_server) + self.loop.run_forever() + + self.thread = Thread(target=run, daemon=True) + self.thread.start() + + async def _broadcast(self, message: str): + for ws in self.clients.copy(): + try: + await ws.send(message) + except Exception: + self.clients.discard(ws) + + def notify_all(self, message: str = "reload"): + if self.loop and self.clients: + asyncio.run_coroutine_threadsafe( + coro=self._broadcast(message), loop=self.loop + ) From a03e9bfb82557489bbca2ff30b9563eb9f6ce184 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sun, 6 Jul 2025 15:23:10 -0400 Subject: [PATCH 13/76] documented server code --- src/zona/server.py | 4 ++-- src/zona/websockets.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/zona/server.py b/src/zona/server.py index 2e2683a..d69161a 100644 --- a/src/zona/server.py +++ b/src/zona/server.py @@ -65,9 +65,9 @@ class LiveReloadHandler(SimpleHTTPRequestHandler): if index_path.is_file(): path = index_path # check if serving html file - if Path(path).suffix in {".html", ".htm"} and self.script != "": + if path.suffix in {".html", ".htm"} and self.script != "": try: - logger.debug("Injecting reload script") + logger.debug(f"Injecting reload script: {path}") # read the html with open(path, "rb") as f: content = f.read().decode("utf-8") diff --git a/src/zona/websockets.py b/src/zona/websockets.py index 1c32494..9049d40 100644 --- a/src/zona/websockets.py +++ b/src/zona/websockets.py @@ -5,6 +5,11 @@ 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] @@ -19,6 +24,7 @@ class WebSocketServer: 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() @@ -26,19 +32,26 @@ class WebSocketServer: 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) @@ -46,6 +59,7 @@ class WebSocketServer: 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 From cefd57d8d70c68fb9bce6b91dbbd5be113e944e7 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sun, 6 Jul 2025 15:35:02 -0400 Subject: [PATCH 14/76] made live reloading optional --- src/zona/server.py | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/zona/server.py b/src/zona/server.py index d69161a..cdd0254 100644 --- a/src/zona/server.py +++ b/src/zona/server.py @@ -129,11 +129,11 @@ class ZonaReloadHandler(FileSystemEventHandler): self, builder: ZonaBuilder, output: Path, - ws_server: WebSocketServer, + ws_server: WebSocketServer | None, ): self.builder: ZonaBuilder = builder self.output: Path = output.resolve() - self.ws_server: WebSocketServer = ws_server + self.ws_server: WebSocketServer | None = ws_server def _trigger_rebuild(self, event: FileSystemEvent): # check if it's an event we care about @@ -141,9 +141,9 @@ class ZonaReloadHandler(FileSystemEventHandler): logger.info(f"Modified: {event.src_path}, rebuilding...") # rebuild static site self.builder.build() - assert self.ws_server - # trigger browser refresh - self.ws_server.notify_all() + 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() @@ -170,6 +170,7 @@ def serve( draft: bool = False, host: str = "localhost", port: int = 8000, + live_reload: bool = True, ): """Serve preview website with live reload and automatic rebuild.""" builder = ZonaBuilder(root, output, draft) @@ -182,21 +183,26 @@ def serve( root = builder.layout.root # 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 - reload_script = make_reload_script(host, ws_port) + if live_reload: + ws_port = port + 1 + ws_server = WebSocketServer(host, ws_port) + ws_server.start() + # generate reload script for injection + reload_script = make_reload_script(host, ws_port) + # 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) - # generate handler with reload script as attribute - handler = make_handler_class(reload_script) # initialize http server httpd = ZonaServer( server_address=(host, port), RequestHandlerClass=handler ) # link websocket server - httpd.set_ws_server(ws_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 ") From 3b32352ba64c91b78a1d3920048c5642f47e3b89 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sun, 6 Jul 2025 15:35:12 -0400 Subject: [PATCH 15/76] updated cli serve interface --- src/zona/cli.py | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/zona/cli.py b/src/zona/cli.py index e928273..8ba46b3 100644 --- a/src/zona/cli.py +++ b/src/zona/cli.py @@ -59,9 +59,7 @@ def build( """ if draft: print("Option override: including drafts.") - builder = ZonaBuilder( - cli_root=root, cli_output=output, draft=draft - ) + builder = ZonaBuilder(cli_root=root, cli_output=output, draft=draft) builder.build() @@ -73,6 +71,16 @@ def serve( help="Directory containing config.yml", ), ] = None, + host: Annotated[ + str, + typer.Option("--host", help="Hostname for live preview server."), + ] = "localhost", + port: Annotated[ + int, + typer.Option( + "--port", "-p", help="Port number for live preview server." + ), + ] = 8000, output: Annotated[ Path | None, typer.Option( @@ -83,6 +91,14 @@ def serve( bool, typer.Option("--draft", "-d", help="Include drafts."), ] = False, + no_live_reload: Annotated[ + bool, + typer.Option( + "--no-live-reload", + "-n", + help="Don't automatically reload web preview.", + ), + ] = False, ): """ Build the website and start a live preview server. @@ -93,7 +109,14 @@ def serve( """ if draft: print("Option override: including drafts.") - server.serve(root, output, draft) + server.serve( + root=root, + output=output, + draft=draft, + host=host, + port=port, + live_reload=not no_live_reload, + ) @app.callback() From 7fc481983da15361a7347e1da0a1532feff1d716 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sun, 6 Jul 2025 15:36:40 -0400 Subject: [PATCH 16/76] formatting --- src/zona/cli.py | 12 +++++++++--- src/zona/websockets.py | 4 +++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/zona/cli.py b/src/zona/cli.py index 8ba46b3..c1017ab 100644 --- a/src/zona/cli.py +++ b/src/zona/cli.py @@ -59,7 +59,9 @@ def build( """ if draft: print("Option override: including drafts.") - builder = ZonaBuilder(cli_root=root, cli_output=output, draft=draft) + builder = ZonaBuilder( + cli_root=root, cli_output=output, draft=draft + ) builder.build() @@ -73,12 +75,16 @@ def serve( ] = None, host: Annotated[ str, - typer.Option("--host", help="Hostname for live preview server."), + typer.Option( + "--host", help="Hostname for live preview server." + ), ] = "localhost", port: Annotated[ int, typer.Option( - "--port", "-p", help="Port number for live preview server." + "--port", + "-p", + help="Port number for live preview server.", ), ] = 8000, output: Annotated[ diff --git a/src/zona/websockets.py b/src/zona/websockets.py index 9049d40..2889af2 100644 --- a/src/zona/websockets.py +++ b/src/zona/websockets.py @@ -40,7 +40,9 @@ class WebSocketServer: asyncio.set_event_loop(self.loop) # start server ws_server = serve( - ws_handler=self._handler, host=self.host, port=self.port + ws_handler=self._handler, + host=self.host, + port=self.port, ) # add server to event loop self.loop.run_until_complete(ws_server) From 28b722ddf4af8a4475e6bc0d5776110dfd34bff6 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sun, 6 Jul 2025 16:11:36 -0400 Subject: [PATCH 17/76] updated readme --- README.md | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 82 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 90abd06..e1f210d 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,16 @@ For an example of a website built with zona, please see [ficd.ca](https://ficd.ca). - - [Features](#features) - [Installation](#installation) - [Usage](#usage) - [Getting Started](#getting-started) + - [Building](#building) + - [Live Preview](#live-preview) + - [How It Works](#how-it-works) - [Site Layout](#site-layout) + - [Templates](#templates) + - [Markdown Footer](#markdown-footer) - [Internal Link Resolution](#internal-link-resolution) - [Syntax Highlighting](#syntax-highlighting) - [Image Labels](#image-labels) @@ -37,12 +41,13 @@ For an example of a website built with zona, please see - [Sitemap](#sitemap) - [Ignore List](#ignore-list) - [Drafts](#drafts) - ## Features -- Live preview server with automatic rebuilding. +- Live preview server: + - Automatic rebuild of site on file changes. + - Live refresh in browser preview. - `jinja2` template support with sensible defaults included. - Basic page, blog post, post list. - Glob ignore. @@ -81,14 +86,61 @@ 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. -To start a live preview session, execute `zona serve`. The server will run until -it's killed by the user, and the website is rebuilt if any source files are -modified. _Note: if you change `config.yml` or any templates, you will need to -restart the preview server_. +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. + +_Note: the previous build is _not_ cleaned before the new site is built. If +you've deleted some pages, you may need to remove the output directory before +rebuilding._ + +### 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._ + +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 `--output/-o` +and `--draft/-d` options from `zona build` are also supported. Finally, the +`--no-live-reload/-n` disables the live browser reloading. _Automatic site +rebuilds are not disabled._ + +**Note**: if the live preview isn't working as expected, try restarting the +server. If you change the configuration or any templates, 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. + +#### 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 @@ -119,6 +171,28 @@ 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. 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`. | + +#### 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.** + ### Internal Link Resolution When zona encounters links in Markdown documents, it attempts to resolve them as @@ -135,7 +209,7 @@ 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 +Zona uses [Pygments] to provide syntax highlighting for fenced code blocks. The following Pygments plugins are included: - [pygments-kakoune](https://git.sr.ht/~ficd/pygments-kakoune) From d098641edbb804a242b5ab3c3633e4dec2f7bfd4 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sun, 6 Jul 2025 16:16:30 -0400 Subject: [PATCH 18/76] updated dependencies --- pyproject.toml | 1 + uv.lock | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 83d80be..c2c63a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "rich>=14.0.0", "typer>=0.16.0", "watchdog>=6.0.0", + "websockets>=15.0.1", ] [project.scripts] diff --git a/uv.lock b/uv.lock index 72489e8..36f5ca8 100644 --- a/uv.lock +++ b/uv.lock @@ -404,6 +404,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + [[package]] name = "zona" version = "0.1.0" @@ -421,6 +452,7 @@ dependencies = [ { name = "rich" }, { name = "typer" }, { name = "watchdog" }, + { name = "websockets" }, ] [package.dev-dependencies] @@ -445,6 +477,7 @@ requires-dist = [ { name = "rich", specifier = ">=14.0.0" }, { name = "typer", specifier = ">=0.16.0" }, { name = "watchdog", specifier = ">=6.0.0" }, + { name = "websockets", specifier = ">=15.0.1" }, ] [package.metadata.requires-dev] From 11387b44811be0988ca01b55ee0f5586481c99c7 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sun, 6 Jul 2025 17:38:06 -0400 Subject: [PATCH 19/76] added TOC extension --- src/zona/markdown.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/zona/markdown.py b/src/zona/markdown.py index b829be6..024b48a 100644 --- a/src/zona/markdown.py +++ b/src/zona/markdown.py @@ -13,6 +13,7 @@ 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 @@ -146,6 +147,7 @@ def md_to_html( FootnoteExtension(), AttrListExtension(), DefListExtension(), + TocExtension(), TableExtension(), AbbrExtension(), SmartyExtension(), From 5bd9e26a494a5a9b0327e162a2b335474ccad0b1 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sun, 6 Jul 2025 17:48:18 -0400 Subject: [PATCH 20/76] fixed an issue where git changes would trigger server rebuild --- src/zona/server.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/zona/server.py b/src/zona/server.py index cdd0254..8e08b0c 100644 --- a/src/zona/server.py +++ b/src/zona/server.py @@ -216,7 +216,12 @@ def serve( # initialize reload handler event_handler = ZonaReloadHandler(builder, output, ws_server) observer = Observer() - observer.schedule(event_handler, path=str(root), recursive=True) + observer.schedule( + event_handler, path=str(root / "content"), recursive=True + ) + observer.schedule( + event_handler, path=str(root / "templates"), recursive=True + ) observer.start() # function to shut down gracefully From 12f4670533a0ae5a0febfedab6e9d2c0b3cf3ee0 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sun, 6 Jul 2025 20:52:37 -0400 Subject: [PATCH 21/76] added latex support --- pyproject.toml | 3 ++- src/zona/markdown.py | 2 ++ uv.lock | 24 ++++++++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c2c63a3..aea7a42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ requires-python = ">=3.12" dependencies = [ "dacite>=1.9.2", "jinja2>=3.1.6", + "l2m4m>=1.0.4", "markdown>=3.8.2", "pygments>=2.19.1", "pygments-ashen>=0.1.3", @@ -51,7 +52,7 @@ reportUnusedCallResult = false reportCallInDefaultInitializer = false enableTypeIgnoreComments = true reportIgnoreCommentWithoutRule = false -allowedUntypedLibraries = ["frontmatter", "pygments", "pymdownx"] +allowedUntypedLibraries = ["frontmatter", "pygments", "pymdownx", "l2m4m"] [tool.ruff] line-length = 70 diff --git a/src/zona/markdown.py b/src/zona/markdown.py index 024b48a..a58b334 100644 --- a/src/zona/markdown.py +++ b/src/zona/markdown.py @@ -3,6 +3,7 @@ from collections.abc import Sequence from pathlib import Path from typing import Any, override +from l2m4m import LaTeX2MathMLExtension from markdown import Markdown from markdown.extensions.abbr import AbbrExtension from markdown.extensions.attr_list import AttrListExtension @@ -157,6 +158,7 @@ def md_to_html( SaneListExtension(), MarkdownInHtmlExtension(), EscapeAllExtension(hardbreak=True), + LaTeX2MathMLExtension(), ] kwargs: dict[str, Any] = { "extensions": extensions, diff --git a/uv.lock b/uv.lock index 36f5ca8..311053e 100644 --- a/uv.lock +++ b/uv.lock @@ -65,6 +65,28 @@ 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 = "markdown" version = "3.8.2" @@ -442,6 +464,7 @@ source = { editable = "." } dependencies = [ { name = "dacite" }, { name = "jinja2" }, + { name = "l2m4m" }, { name = "markdown" }, { name = "pygments" }, { name = "pygments-ashen" }, @@ -467,6 +490,7 @@ dev = [ requires-dist = [ { name = "dacite", specifier = ">=1.9.2" }, { name = "jinja2", specifier = ">=3.1.6" }, + { name = "l2m4m", specifier = ">=1.0.4" }, { name = "markdown", specifier = ">=3.8.2" }, { name = "pygments", specifier = ">=2.19.1" }, { name = "pygments-ashen", specifier = ">=0.1.3" }, From eacaeba2ab2aef5685ac67b885c18f5dc3230c5c Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sun, 6 Jul 2025 21:04:16 -0400 Subject: [PATCH 22/76] update markdown extensions --- src/zona/config.py | 4 +--- src/zona/markdown.py | 11 ++++++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/zona/config.py b/src/zona/config.py index 62b2bef..de3c985 100644 --- a/src/zona/config.py +++ b/src/zona/config.py @@ -59,9 +59,7 @@ IGNORELIST = [".marksman.toml"] class ZonaConfig: base_url: str = "/" # dictionary where key is name, value is url - sitemap: SitemapConfig = field( - default_factory=lambda: {"Home": "/"} - ) + sitemap: SitemapConfig = field(default_factory=lambda: {"Home": "/"}) # list of globs relative to content that should be ignored ignore: list[str] = field(default_factory=lambda: IGNORELIST) markdown: MarkdownConfig = field(default_factory=MarkdownConfig) diff --git a/src/zona/markdown.py b/src/zona/markdown.py index a58b334..f41a45d 100644 --- a/src/zona/markdown.py +++ b/src/zona/markdown.py @@ -4,6 +4,8 @@ from pathlib import Path from typing import Any, override from l2m4m import LaTeX2MathMLExtension + +# from l2m4m import LaTeX2MathMLExtension from markdown import Markdown from markdown.extensions.abbr import AbbrExtension from markdown.extensions.attr_list import AttrListExtension @@ -18,9 +20,12 @@ from markdown.extensions.toc import TocExtension from markdown.treeprocessors import Treeprocessor from pygments.formatters.html import HtmlFormatter from pymdownx.betterem import BetterEmExtension +from pymdownx.caret import InsertSupExtension from pymdownx.escapeall import EscapeAllExtension from pymdownx.inlinehilite import InlineHiliteExtension +from pymdownx.smartsymbols import SmartSymbolsExtension from pymdownx.superfences import SuperFencesCodeExtension +from pymdownx.tilde import DeleteSubExtension from zona import util from zona.config import ZonaConfig @@ -152,9 +157,9 @@ def md_to_html( TableExtension(), AbbrExtension(), SmartyExtension(), - "pymdownx.tilde", - "pymdownx.caret", - "pymdownx.smartsymbols", + InsertSupExtension(), + DeleteSubExtension(), + SmartSymbolsExtension(), SaneListExtension(), MarkdownInHtmlExtension(), EscapeAllExtension(hardbreak=True), From 3037aa88d87609a6853561a254fc77421d596861 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sun, 6 Jul 2025 21:09:55 -0400 Subject: [PATCH 23/76] added math frontmatter option --- src/zona/builder.py | 21 +++++++-------------- src/zona/markdown.py | 5 ++++- src/zona/metadata.py | 1 + 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/zona/builder.py b/src/zona/builder.py index 8f7a3dc..31140c8 100644 --- a/src/zona/builder.py +++ b/src/zona/builder.py @@ -47,9 +47,7 @@ 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( @@ -72,13 +70,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 @@ -90,9 +86,7 @@ class ZonaBuilder: layout.output ) item.url = ( - "" - if rel_url == Path(".") - else rel_url.as_posix() + "" if rel_url == Path(".") else rel_url.as_posix() ) items.append(item) self.items = items @@ -118,9 +112,7 @@ class ZonaBuilder: # write code highlighting stylesheet if self.config.markdown.syntax_highlighting.enabled: pygments_style = zmd.get_style_defs(self.config) - pygments_path = ( - self.layout.output / "static" / "pygments.css" - ) + pygments_path = self.layout.output / "static" / "pygments.css" util.ensure_parents(pygments_path) pygments_path.write_text(pygments_style) for item in self.item_map.values(): @@ -137,6 +129,7 @@ class ZonaBuilder: source=item.source, layout=self.layout, item_map=self.item_map, + metadata=item.metadata, ) # TODO: test this rendered = templater.render_item(item, raw_html) diff --git a/src/zona/markdown.py b/src/zona/markdown.py index f41a45d..ce73016 100644 --- a/src/zona/markdown.py +++ b/src/zona/markdown.py @@ -31,6 +31,7 @@ from zona import util from zona.config import ZonaConfig from zona.layout import Layout from zona.log import get_logger +from zona.metadata import Metadata from zona.models import Item logger = get_logger() @@ -143,6 +144,7 @@ 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(), @@ -163,12 +165,13 @@ def md_to_html( SaneListExtension(), MarkdownInHtmlExtension(), EscapeAllExtension(hardbreak=True), - LaTeX2MathMLExtension(), ] kwargs: dict[str, Any] = { "extensions": extensions, "tab_length": 2, } + if metadata and metadata.math: + kwargs["extensions"].append(LaTeX2MathMLExtension()) if config: kwargs["extensions"].extend( [ diff --git a/src/zona/metadata.py b/src/zona/metadata.py index 63fc43f..b079266 100644 --- a/src/zona/metadata.py +++ b/src/zona/metadata.py @@ -24,6 +24,7 @@ class Metadata: template: str | None = None post: bool | None = None draft: bool = False + math: bool = True def parse_date(raw_date: str | date | object) -> date: From d739ef5d95f2f508658b357ac61e39309da892ad Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sun, 6 Jul 2025 21:19:07 -0400 Subject: [PATCH 24/76] updated readme --- README.md | 288 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 159 insertions(+), 129 deletions(-) diff --git a/README.md b/README.md index e1f210d..d11c3b2 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,30 @@

zona

[zona](https://sr.ht/~ficd/zona) is an _opinionated_ static site generator -written in Python. From a structured directory of Markdown content, zona builds -a simple static website. It's designed to get out of your way and let you focus -on writing. +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. -**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. +**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. -**Note:** This project is in early development, there are no versioned releases -yet, and breaking changes are likely. Versioned releases will be made and zona -will be published to PyPI once it's stable. zona was previously implemented in -Go; I decided to rewrite the project in Python. If you're interested in seeing -the previous codebase (which is feature incomplete), visit the -[~ficd/zona-go](https://git.sr.ht/~ficd/zona-go) repository. +**Note:** This project is in early development, there are no versioned +releases yet, and breaking changes are likely. Versioned releases will be +made and zona will be published to PyPI once it's stable. zona was +previously implemented in Go; I decided to rewrite the project in Python. +If you're interested in seeing the previous codebase (which is feature +incomplete), visit the [~ficd/zona-go](https://git.sr.ht/~ficd/zona-go) +repository. For an example of a website built with zona, please see [ficd.ca](https://ficd.ca). + - [Features](#features) - [Installation](#installation) - [Usage](#usage) @@ -41,6 +44,7 @@ For an example of a website built with zona, please see - [Sitemap](#sitemap) - [Ignore List](#ignore-list) - [Drafts](#drafts) + ## Features @@ -55,15 +59,16 @@ For an example of a website built with zona, please see - 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). + - 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. + - Many `python-markdown` extensions enabled, including footnotes, + tables, abbreviations, etc. + - LaTeX support. ## Installation @@ -81,66 +86,68 @@ 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. +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. +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. +`--output/-o` flag. The `--draft/-d` flag includes draft posts in the +output. -_Note: the previous build is _not_ cleaned before the new site is built. If -you've deleted some pages, you may need to remove the output directory before -rebuilding._ +_Note: the previous build is _not_ cleaned before the new site is built. +If you've deleted some pages, you may need to remove the output directory +before rebuilding._ ### 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.) +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._ +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._ -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. +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 `--output/-o` -and `--draft/-d` options from `zona build` are also supported. Finally, the -`--no-live-reload/-n` disables the live browser reloading. _Automatic site -rebuilds are not disabled._ +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 `--output/-o` and `--draft/-d` options from `zona build` are +also supported. Finally, the `--no-live-reload/-n` disables the live +browser reloading. _Automatic site rebuilds are not disabled._ -**Note**: if the live preview isn't working as expected, try restarting the -server. If you change the configuration or any templates, 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. +**Note**: if the live preview isn't working as expected, try restarting +the server. If you change the configuration or any templates, 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. #### 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. +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.** +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 @@ -153,30 +160,32 @@ 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 prioritized if it exists. `public` is the built site output — -it's recommended to add this path to your `.gitignore`. +The **root** of the zona **project** _must_ contain the configuration +file, `config.yml`, and a directory called `content`. A directory called +`templates` is optional, and prioritized if it exists. `public` is the +built site output — it's recommended to add this path to your +`.gitignore`. The `content` directory is the **root of the website**. 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 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. +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. 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: +modify the existing templates or create your own. 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 | | ---------- | ------------------------------------------------------ | @@ -188,40 +197,43 @@ following public variables are made available to the template engine: #### 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.** +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.** ### 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. +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. +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: +Zona uses [Pygments] to provide syntax highlighting for fenced code +blocks. The following Pygments plugins are included: - [pygments-kakoune](https://git.sr.ht/~ficd/pygments-kakoune) - - A lexer providing for highlighting Kakoune code. Available under the `kak` - and `kakrc` aliases. + - A lexer providing for highlighting Kakoune code. Available under the + `kak` and `kakrc` aliases. - [pygments-ashen](https://git.sr.ht/~ficd/ashen/tree/main/item/pygments/README.md) - - An implementation of the [Ashen](https://git.sr.ht/~ficd/ashen) theme for - Pygments. + - An implementation of the [Ashen](https://git.sr.ht/~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): +available in zona's Python environment. For example, you can give zona +access to [Catppucin](https://github.com/catppuccin/python): ```yaml # config.yml @@ -236,9 +248,9 @@ Then, run zona with the following `uv` command: 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: +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="")` @@ -247,11 +259,27 @@ will be rendered as (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: +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) @@ -266,14 +294,14 @@ The above results in the following HTML: ``` The `image-container` class is provided as a convenience for styling. The -default stylesheet centers the label under the image. Note: _links_ inside image -captions are not currently supported. I am looking into a solution. +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: +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 | | ------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------ | @@ -285,6 +313,7 @@ available: | `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. | +| `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/). @@ -305,9 +334,10 @@ 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. +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: @@ -318,22 +348,22 @@ 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 +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`. +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. +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. +**Note:** Currently, not every configuration value is actually used. Only +the useful settings are listed here. Please see the default configuration: @@ -365,10 +395,10 @@ blog: ### 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: +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: @@ -380,17 +410,17 @@ sitemap: ### 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. +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. +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://sr.ht/~ficd/ashen [Pygments]: https://pygments.org/ From b8b8fef72c741606ed1165a0c07c42cca8060176 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Thu, 10 Jul 2025 18:17:21 -0400 Subject: [PATCH 25/76] updated link in readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d11c3b2..5b35e8c 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ incomplete), visit the [~ficd/zona-go](https://git.sr.ht/~ficd/zona-go) repository. For an example of a website built with zona, please see -[ficd.ca](https://ficd.ca). +[ficd.sh](https://ficd.sh). From 0ee8094cc9c366a813d8f7938d35a9be7f7647e1 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Fri, 11 Jul 2025 14:15:03 -0400 Subject: [PATCH 26/76] feature: only external links open in new tab --- README.md | 3 +++ src/zona/config.py | 6 ++++++ src/zona/markdown.py | 29 ++++++++++++++++++++--------- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 5b35e8c..1d979e0 100644 --- a/README.md +++ b/README.md @@ -379,6 +379,8 @@ markdown: enabled: true theme: ashen wrap: false + links: + external_new_tab: true blog: dir: blog ``` @@ -391,6 +393,7 @@ blog: | `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. | | `blog.dir` | Name of a directory relative to `content/` whose children are automatically considered posts. | ### Sitemap diff --git a/src/zona/config.py b/src/zona/config.py index de3c985..63baac0 100644 --- a/src/zona/config.py +++ b/src/zona/config.py @@ -37,6 +37,11 @@ class HighlightingConfig: wrap: bool = False +@dataclass +class LinksConfig: + external_new_tab: bool = True + + @dataclass class MarkdownConfig: image_labels: bool = True @@ -44,6 +49,7 @@ class MarkdownConfig: syntax_highlighting: HighlightingConfig = field( default_factory=HighlightingConfig ) + links: LinksConfig = field(default_factory=LinksConfig) @dataclass diff --git a/src/zona/markdown.py b/src/zona/markdown.py index ce73016..5a71d3b 100644 --- a/src/zona/markdown.py +++ b/src/zona/markdown.py @@ -1,11 +1,10 @@ import xml.etree.ElementTree as etree from collections.abc import Sequence +from logging import Logger from pathlib import Path from typing import Any, override from l2m4m import LaTeX2MathMLExtension - -# from l2m4m import LaTeX2MathMLExtension from markdown import Markdown from markdown.extensions.abbr import AbbrExtension from markdown.extensions.attr_list import AttrListExtension @@ -34,8 +33,6 @@ from zona.log import get_logger from zona.metadata import Metadata from zona.models import Item -logger = get_logger() - class ZonaImageTreeprocessor(Treeprocessor): """Implement Zona's image caption rendering.""" @@ -43,6 +40,7 @@ class ZonaImageTreeprocessor(Treeprocessor): def __init__(self, md: Markdown): super().__init__() self.md: Markdown = md + self.logger: Logger = get_logger() @override def run(self, root: etree.Element): @@ -87,6 +85,7 @@ class ZonaLinkTreeprocessor(Treeprocessor): ): super().__init__() self.resolve: bool = resolve + self.logger: Logger = get_logger() if self.resolve: assert source is not None assert layout is not None @@ -103,9 +102,15 @@ class ZonaLinkTreeprocessor(Treeprocessor): if not href: continue if self.resolve: + assert self.config cur = Path(href) _href = href - if href.startswith("/"): + same_file = False + resolved = Path() + # href starting with anchor reference the current file + if href.startswith("#"): + same_file = True + elif href.startswith("/"): # resolve relative to content root resolved = ( self.layout.content / cur.relative_to("/") @@ -114,19 +119,25 @@ class ZonaLinkTreeprocessor(Treeprocessor): # 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(): + # that isn't the self file + if not same_file and resolved.exists(): item = self.item_map.get(resolved) if item: href = util.normalize_url(item.url) element.set("href", href) - logger.debug( + self.logger.debug( f"Link in file {self.source}: {_href} resolved to {href}" ) else: - logger.debug( + self.logger.debug( f"Warning: resolved path {resolved} not found in item map" ) - element.set("target", "_blank") + # open link in new tab if not self-link + elif ( + self.config.markdown.links.external_new_tab + and not same_file + ): + element.set("target", "_blank") def get_formatter(config: ZonaConfig): From 55df755596192ae67b5119f520be4f5c150d6789 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Fri, 11 Jul 2025 14:46:58 -0400 Subject: [PATCH 27/76] added toc anchor links, updated default css --- src/zona/data/content/static/style.css | 58 ++++++++++++++++++++++++-- src/zona/markdown.py | 4 +- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/zona/data/content/static/style.css b/src/zona/data/content/static/style.css index 0ebd3fb..641f9cf 100644 --- a/src/zona/data/content/static/style.css +++ b/src/zona/data/content/static/style.css @@ -17,6 +17,46 @@ body { padding-left: calc(100vw - 100%); } +.toclink { + position: relative; + text-decoration: none; + color: inherit; +} + +.toclink::before { + content: "#"; + position: absolute; + right: 100%; + margin-right: 0.25em; + top: 50%; + transform: translateY(-50%); + opacity: 0; + transition: opacity 0.2s ease; +} + +h1 .toclink::before { + content: "#"; +} + +h2 .toclink::before { + content: "#"; +} + +h3 .toclink::before { + content: "##"; +} + +h4 .toclink::before { + content: "###"; +} + +.toclink:hover::before { + opacity: 1; +} +.toclink:hover { + background-color: transparent; +} + /* h1, */ h2, h3, @@ -94,6 +134,8 @@ a:hover { background: var(--main-transparent); } +max-width: 100%; +overflow: hidden; img { display: block; margin-left: auto; @@ -128,7 +170,7 @@ pre { white-space: pre; word-wrap: break-word; overflow-wrap: break-word; - font-family: 'Fira Code', 'Consolas', 'Courier New', monospace; + font-family: monospace; font-size: 0.95em; } @@ -183,11 +225,20 @@ small a { .image-container { text-align: center; margin: 20px 0; + max-width: 100%; + overflow: hidden; /* Optional: add some spacing around the image container */ } .image-container img { - /* max-width: 308px; */ + max-width: 100%; + width: auto; + max-height: 100%; + height: auto; +} + +.fixed .image-container img { + max-width: 308px; max-height: 308px; } @@ -272,7 +323,7 @@ tbody tr:hover { } table code { - font-family: 'Fira Code', 'Consolas', monospace; + font-family: monospace; font-size: 0.85em; background: #1d1d1d; padding: 0.1em 0.25em; @@ -286,3 +337,4 @@ caption { } + diff --git a/src/zona/markdown.py b/src/zona/markdown.py index 5a71d3b..c5a83d4 100644 --- a/src/zona/markdown.py +++ b/src/zona/markdown.py @@ -166,7 +166,9 @@ def md_to_html( FootnoteExtension(), AttrListExtension(), DefListExtension(), - TocExtension(), + TocExtension( + anchorlink=True, + ), TableExtension(), AbbrExtension(), SmartyExtension(), From 71e541aa5e02a3e28a8b62bc7a1e609a9f6f7b78 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sat, 12 Jul 2025 00:34:14 -0400 Subject: [PATCH 28/76] fix: root internal links are now recognized properly --- src/zona/markdown.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/zona/markdown.py b/src/zona/markdown.py index c5a83d4..1132f22 100644 --- a/src/zona/markdown.py +++ b/src/zona/markdown.py @@ -118,16 +118,26 @@ class ZonaLinkTreeprocessor(Treeprocessor): else: # treat as relative link and try to resolve resolved = (self.source.parent / cur).resolve() + # check if the link is internal + internal = same_file + if not same_file: + for suffix in {".md", ".html"}: + if resolved.with_suffix(suffix).exists(): + internal = True + resolved = resolved.with_suffix(suffix) + break # only substitute if link points to an actual file # that isn't the self file - if not same_file and resolved.exists(): + if not same_file and internal: item = self.item_map.get(resolved) if item: href = util.normalize_url(item.url) - element.set("href", href) - self.logger.debug( - f"Link in file {self.source}: {_href} resolved to {href}" - ) + # don't sub if it's already correct lol + if _href != href: + element.set("href", href) + self.logger.debug( + f"Link in file {self.source}: {_href} resolved to {href}" + ) else: self.logger.debug( f"Warning: resolved path {resolved} not found in item map" From 8793284bd6a44b5145ee3fc373a5f6c66a971f19 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sat, 12 Jul 2025 00:43:53 -0400 Subject: [PATCH 29/76] feat: clean output directory on build --- src/zona/builder.py | 12 +++++++++--- src/zona/config.py | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/zona/builder.py b/src/zona/builder.py index 31140c8..de16bf5 100644 --- a/src/zona/builder.py +++ b/src/zona/builder.py @@ -1,3 +1,4 @@ +import shutil from datetime import date from pathlib import Path @@ -50,9 +51,7 @@ class ZonaBuilder: if path.name.endswith(".md") and not path.is_relative_to( layout.root / "content" / "static" ): - logger.debug( - f"Parsing {path.name} as Markdown document." - ) + logger.debug(f"Parsing {path.name}.") item.metadata, item.content = parse_metadata(path) if ( item.metadata.draft @@ -140,6 +139,13 @@ 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...") + shutil.rmtree(self.layout.output) logger.debug("Discovering...") self._discover() logger.debug("Building...") diff --git a/src/zona/config.py b/src/zona/config.py index 63baac0..4bd7a42 100644 --- a/src/zona/config.py +++ b/src/zona/config.py @@ -54,7 +54,7 @@ class MarkdownConfig: @dataclass class BuildConfig: - # clean_output_dir: bool = True + clean_output_dir: bool = True include_drafts: bool = False From bdd7558999d4fd28a2eb3aaeee149d5d2d05bb7b Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sat, 12 Jul 2025 00:48:22 -0400 Subject: [PATCH 30/76] server now builds to tempdir by default --- src/zona/cli.py | 12 ++--- src/zona/server.py | 118 +++++++++++++++++++++++---------------------- 2 files changed, 65 insertions(+), 65 deletions(-) diff --git a/src/zona/cli.py b/src/zona/cli.py index c1017ab..f6f40af 100644 --- a/src/zona/cli.py +++ b/src/zona/cli.py @@ -59,9 +59,7 @@ def build( """ if draft: print("Option override: including drafts.") - builder = ZonaBuilder( - cli_root=root, cli_output=output, draft=draft - ) + builder = ZonaBuilder(cli_root=root, cli_output=output, draft=draft) builder.build() @@ -75,9 +73,7 @@ def serve( ] = None, host: Annotated[ str, - typer.Option( - "--host", help="Hostname for live preview server." - ), + typer.Option("--host", help="Hostname for live preview server."), ] = "localhost", port: Annotated[ int, @@ -90,7 +86,9 @@ def serve( output: Annotated[ Path | None, typer.Option( - "--output", "-o", help="Location to write built website" + "--output", + "-o", + help="Location to write built website. Temporary directory by default.", ), ] = None, draft: Annotated[ diff --git a/src/zona/server.py b/src/zona/server.py index 8e08b0c..c18f3a8 100644 --- a/src/zona/server.py +++ b/src/zona/server.py @@ -2,6 +2,7 @@ import io import os import signal import sys +import tempfile import threading from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -173,67 +174,68 @@ def serve( live_reload: bool = True, ): """Serve preview website with live reload and automatic rebuild.""" - builder = ZonaBuilder(root, output, draft) - # 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 + # create temp dir, automatic cleanup + with tempfile.TemporaryDirectory() as tmp: + builder = ZonaBuilder(root, Path(tmp), draft) + # 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 - # spin up websocket server for live reloading - if live_reload: - ws_port = port + 1 - ws_server = WebSocketServer(host, ws_port) - ws_server.start() - # generate reload script for injection - reload_script = make_reload_script(host, ws_port) - # 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 ") + # spin up websocket server for live reloading + if live_reload: + ws_port = port + 1 + ws_server = WebSocketServer(host, ws_port) + ws_server.start() + # generate reload script for injection + reload_script = make_reload_script(host, ws_port) + # 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 ") - # start server in a thread - server_thread = threading.Thread( - target=httpd.serve_forever, daemon=True - ) - server_thread.start() + # start server in a thread + server_thread = threading.Thread( + target=httpd.serve_forever, daemon=True + ) + server_thread.start() - # initialize reload handler - event_handler = ZonaReloadHandler(builder, output, ws_server) - observer = Observer() - observer.schedule( - event_handler, path=str(root / "content"), recursive=True - ) - observer.schedule( - event_handler, path=str(root / "templates"), 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 + ) + observer.schedule( + event_handler, path=str(root / "templates"), recursive=True + ) + observer.start() - # function to shut down gracefully - def shutdown_handler(_a: int, _b: FrameType | None): - logger.info("Shutting down...") - observer.stop() - httpd.shutdown() - sys.exit(0) + # function to shut down gracefully + def shutdown_handler(_a: int, _b: FrameType | None): + print("Shutting down...") + observer.stop() + httpd.shutdown() - # register shutdown handler - 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() + # start file change watcher + observer.join() From 8d17572d1230733b4353ef83defc92569630cf7b Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sat, 12 Jul 2025 00:53:43 -0400 Subject: [PATCH 31/76] server now includes drafts by default --- README.md | 10 +++++++--- src/zona/cli.py | 12 +++++++----- src/zona/server.py | 2 +- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 1d979e0..9ae4ef4 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ For an example of a website built with zona, please see - [Markdown Footer](#markdown-footer) - [Internal Link Resolution](#internal-link-resolution) - [Syntax Highlighting](#syntax-highlighting) + - [Markdown Extensions](#markdown-extensions) - [Image Labels](#image-labels) - [Frontmatter](#frontmatter) - [Post List](#post-list) @@ -125,9 +126,12 @@ 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 `--output/-o` and `--draft/-d` options from `zona build` are -also supported. Finally, the `--no-live-reload/-n` disables the live -browser reloading. _Automatic site rebuilds are not disabled._ +`8000`). The `--no-live-reload/-n` disables the live browser reloading +(_automatic site rebuilds are not disabled_). + +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 or any templates, the server diff --git a/src/zona/cli.py b/src/zona/cli.py index f6f40af..1d32c4c 100644 --- a/src/zona/cli.py +++ b/src/zona/cli.py @@ -91,9 +91,9 @@ def serve( help="Location to write built website. Temporary directory by default.", ), ] = None, - draft: Annotated[ + final: Annotated[ bool, - typer.Option("--draft", "-d", help="Include drafts."), + typer.Option("--final", "-f", help="Don't include drafts."), ] = False, no_live_reload: Annotated[ bool, @@ -111,12 +111,14 @@ def serve( Optionally specify the ROOT and OUTPUT directories. """ - if draft: - print("Option override: including drafts.") + if final: + print("Preview without drafts.") + else: + print("Preview with drafts.") server.serve( root=root, output=output, - draft=draft, + draft=not final, host=host, port=port, live_reload=not no_live_reload, diff --git a/src/zona/server.py b/src/zona/server.py index c18f3a8..fa0c033 100644 --- a/src/zona/server.py +++ b/src/zona/server.py @@ -168,7 +168,7 @@ class ZonaReloadHandler(FileSystemEventHandler): def serve( root: Path | None = None, output: Path | None = None, - draft: bool = False, + draft: bool = True, host: str = "localhost", port: int = 8000, live_reload: bool = True, From dacea2756af75d1151788cc0c1b2eefbead3c01f Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sun, 13 Jul 2025 03:13:58 -0400 Subject: [PATCH 32/76] fix: live preview rebuilds no longer crash the server When cleaning the previous build, we deleted the entire directory, which caused problems. Now, we only clean the output dir's children, but leave the dir itself intact. --- src/zona/builder.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/zona/builder.py b/src/zona/builder.py index de16bf5..12b5458 100644 --- a/src/zona/builder.py +++ b/src/zona/builder.py @@ -145,7 +145,13 @@ class ZonaBuilder: and self.layout.output.is_dir() ): logger.debug("Removing stale output...") - shutil.rmtree(self.layout.output) + # only remove output dir's children + # to avoid breaking live preview + for child in self.layout.output.iterdir(): + if child.is_file() or child.is_symlink(): + child.unlink() + elif child.is_dir(): + shutil.rmtree(child) logger.debug("Discovering...") self._discover() logger.debug("Building...") From 585a987c3fab06e83d3ff3dbac9e02cbcbc3adc0 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sun, 13 Jul 2025 16:04:17 -0400 Subject: [PATCH 33/76] updated default css --- src/zona/data/content/static/style.css | 338 ++++++++++++++----------- 1 file changed, 186 insertions(+), 152 deletions(-) diff --git a/src/zona/data/content/static/style.css b/src/zona/data/content/static/style.css index 641f9cf..284a5ce 100644 --- a/src/zona/data/content/static/style.css +++ b/src/zona/data/content/static/style.css @@ -1,20 +1,20 @@ :root { - --main-text-color: #b4b4b4; - --main-bg-color: #121212; - --main-link-color: #df6464; - --main-heading-color: #df6464; - --main-bullet-color: #d87c4a; - --main-transparent: rgba(255, 255, 255, 0.15); - --main-small-text-color: rgba(255, 255, 255, 0.45); + --main-text-color: #b4b4b4; + --main-bg-color: #121212; + --main-link-color: #df6464; + --main-heading-color: #df6464; + --main-bullet-color: #d87c4a; + --main-transparent: rgba(255, 255, 255, 0.15); + --main-small-text-color: rgba(255, 255, 255, 0.45); } body { - line-height: 1.6; - font-size: 18px; - font-family: sans-serif; - background: var(--main-bg-color); - color: var(--main-text-color); - padding-left: calc(100vw - 100%); + 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%); } .toclink { @@ -63,278 +63,312 @@ h3, h4, h5, h6 { - color: var(--main-heading-color); + color: var(--main-heading-color); } h1 { - margin-block-start: 0.67rem; - margin-block-end: 0.67rem; - font-size: 2rem; - font-weight: bold; + margin-block-start: 0.67rem; + margin-block-end: 0.67rem; + font-size: 2rem; + font-weight: bold; } article h1:first-of-type { - margin-block-start: 1.67rem; + margin-block-start: 1.67rem; } h2 { - margin-block-start: 0.83rem; - margin-block-end: 0.83rem; - font-size: 1.5rem; - font-weight: bold; + margin-block-start: 0.83rem; + margin-block-end: 0.83rem; + font-size: 1.5rem; + font-weight: bold; } h3 { - margin-block-start: 1rem; - margin-block-end: 1rem; - font-size: 1.17em; - font-weight: bold; + margin-block-start: 1rem; + margin-block-end: 1rem; + font-size: 1.17em; + font-weight: bold; } h4 { - margin-block-start: 1.33rem; - margin-block-end: 1.33rem; - font-size: 1rem; - font-weight: bold; + margin-block-start: 1.33rem; + margin-block-end: 1.33rem; + font-size: 1rem; + font-weight: bold; } -article h1+h4:first-of-type { - margin-block-start: 0rem; +article h1 + h4:first-of-type { + margin-block-start: 0rem; } h5 { - margin-block-start: 1.67rem; - margin-block-end: 1.67rem; - font-size: 0.83rem; - font-weight: bold; + margin-block-start: 1.67rem; + margin-block-end: 1.67rem; + font-size: 0.83rem; + font-weight: bold; } h6 { - margin-block-start: 2.33rem; - margin-block-end: 2.33rem; - font-size: 0.67rem; - font-weight: bold; + margin-block-start: 2.33rem; + margin-block-end: 2.33rem; + font-size: 0.67rem; + font-weight: bold; } ul { - list-style-type: disc; - /* or any other list style */ + list-style-type: disc; + /* or any other list style */ } li::marker { - color: var(--main-bullet-color); - /* Change this to your desired color */ + color: var(--main-bullet-color); + /* Change this to your desired color */ } a { - color: var(--main-link-color); + color: var(--main-link-color); + text-decoration: underline; } a:hover { - background: var(--main-transparent); + background: var(--main-transparent); } max-width: 100%; overflow: hidden; img { - display: block; - margin-left: auto; - margin-right: auto; - width: auto; - height: auto; + display: block; + margin-left: auto; + margin-right: auto; + width: auto; + height: auto; } blockquote { - color: var(--main-small-text-color); - border-left: 3px solid var(--main-transparent); - padding: 0 1rem; - margin-left: 0; - margin-right: 0; + color: var(--main-small-text-color); + border-left: 3px solid var(--main-transparent); + padding: 0 1rem; + margin-left: 0; + margin-right: 0; } hr { - border: none; - height: 1px; - background: var(--main-small-text-color); + border: none; + height: 1px; + background: var(--main-small-text-color); } code { - background: var(--main-transparent); - border-radius: 0.1875rem; - /* padding: .0625rem .1875rem; */ - /* margin: 0 .1875rem; */ + background: var(--main-transparent); + border-radius: 0.1875rem; + /* padding: .0625rem .1875rem; */ + /* margin: 0 .1875rem; */ } code, pre { - white-space: pre; - word-wrap: break-word; - overflow-wrap: break-word; - font-family: monospace; - font-size: 0.95em; + white-space: pre; + word-wrap: break-word; + overflow-wrap: break-word; + font-family: + ui-monospace, + SFMono-Regular, + SF Mono, + Menlo, + Consolas, + Liberation Mono, + monospace; + font-size: 0.95em; } pre { - background-color: #151515; - color: #d5d5d5; - padding: 1em; - border-radius: 5px; - line-height: 1.5; - overflow-x: auto; + background-color: #151515; + color: #d5d5d5; + padding: 1em; + border-radius: 5px; + line-height: 1.5; + overflow-x: auto; } /* Inline code styling */ :not(pre) > code { - padding: 0.2em 0.4em; - border-radius: 3px; - background-color: #1d1d1d; - color: #d5d5d5; - font-size: 0.85em; + padding: 0.2em 0.4em; + font-size: 0.85em; + line-height: 1; + background-color: #1d1d1d; + border-radius: 6px; + vertical-align: middle; + font-family: + ui-monospace, + SFMono-Regular, + SF Mono, + Menlo, + Consolas, + Liberation Mono, + monospace; } /* Block code styling (inherits from pre) */ pre code { - padding: 0; - border-radius: 0; - background: none; + padding: 0; + border-radius: 0; + background: none; } small { - font-size: 0.95rem; - color: var(--main-small-text-color); + font-size: 0.95rem; + color: var(--main-small-text-color); } small a { - color: inherit; - /* Inherit the color of the surrounding text */ - text-decoration: underline; - /* Optional: Keep the underline to indicate a link */ + color: inherit; + /* Inherit the color of the surrounding text */ + text-decoration: underline; + /* Optional: Keep the underline to indicate a link */ } .title-container { - display: flex; - justify-content: center; - align-items: center; - text-align: center; + display: flex; + justify-content: center; + align-items: center; + text-align: center; } .title-container h1 { - margin: 0; + margin: 0; } .image-container { - text-align: center; - margin: 20px 0; - max-width: 100%; - overflow: hidden; - /* Optional: add some spacing around the image container */ + text-align: center; + margin: 20px 0; + max-width: 100%; + overflow: hidden; + /* Optional: add some spacing around the image container */ } .image-container img { - max-width: 100%; - width: auto; - max-height: 100%; - height: auto; + max-width: 100%; + width: auto; + max-height: 100%; + height: auto; } .fixed .image-container img { - max-width: 308px; - max-height: 308px; + max-width: 308px; + max-height: 308px; } .image-container small { - display: block; - /* Ensure the caption is on a new line */ - margin-top: 5px; - /* Optional: adjust spacing between image and caption */ + display: block; + /* Ensure the caption is on a new line */ + margin-top: 5px; + /* Optional: adjust spacing between image and caption */ } .image-container small a { - color: inherit; - /* Ensure the link color matches the small text */ - text-decoration: underline; - /* Optional: underline to indicate a link */ + color: inherit; + /* Ensure the link color matches the small text */ + text-decoration: underline; + /* Optional: underline to indicate a link */ } #header ul { - list-style-type: none; - padding-left: 0; + list-style-type: none; + padding-left: 0; } #header li { - display: inline; - font-size: 1.2rem; - margin-right: 1.2rem; + display: inline; + font-size: 1.2rem; + margin-right: 1.2rem; } #container { - margin: 2.5rem auto; - width: 90%; - max-width: 60ch; + margin: 2.5rem auto; + width: 90%; + max-width: 60ch; } #postlistdiv ul { - list-style-type: none; - padding-left: 0; + list-style-type: none; + padding-left: 0; } .moreposts { - font-size: 0.95rem; - padding-left: 0.5rem; + font-size: 0.95rem; + padding-left: 0.5rem; } #nextprev { - text-align: center; - margin-top: 1.4rem; - font-size: 0.95rem; + text-align: center; + margin-top: 1.4rem; + font-size: 0.95rem; } #footer { - color: var(--main-small-text-color); + color: var(--main-small-text-color); } table { - border-collapse: collapse; - margin: 1.5rem auto; - width: 100%; - max-width: 100%; - font-size: 0.85rem; - text-align: left; /* Use center if you prefer */ + 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; + 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); + 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); + background-color: rgba(255, 255, 255, 0.02); } tbody tr:hover { - background-color: rgba(255, 255, 255, 0.05); + background-color: rgba(255, 255, 255, 0.05); } table code { - font-family: monospace; - font-size: 0.85em; - background: #1d1d1d; - padding: 0.1em 0.25em; - border-radius: 3px; + 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); + margin-top: 0.5rem; + font-size: 0.8rem; + color: var(--main-small-text-color); } +a > code { + text-decoration: none; + color: inherit; +} +a:has(> code) { + text-decoration: none; +} +a:hover > code { + background-color: var(--main-transparent); +} From 1c64d1f431e4ce0b37ed697aa23fc04233087ff8 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sun, 13 Jul 2025 16:54:04 -0400 Subject: [PATCH 34/76] make injection script template a data file --- src/zona/data/server/inject.js | 6 ++++++ src/zona/server.py | 21 ++++++++++--------- src/zona/util.py | 37 +++++++++++++++++++++++++++++----- 3 files changed, 49 insertions(+), 15 deletions(-) create mode 100644 src/zona/data/server/inject.js diff --git a/src/zona/data/server/inject.js b/src/zona/data/server/inject.js new file mode 100644 index 0000000..550830f --- /dev/null +++ b/src/zona/data/server/inject.js @@ -0,0 +1,6 @@ +const ws = new WebSocket("__SOCKET_ADDRESS_"); +ws.onmessage = event => { + if (event.data === "reload") { + location.reload(); + } +}; diff --git a/src/zona/server.py b/src/zona/server.py index fa0c033..b2e0a64 100644 --- a/src/zona/server.py +++ b/src/zona/server.py @@ -13,6 +13,7 @@ from rich import print from watchdog.events import FileSystemEvent, FileSystemEventHandler from watchdog.observers import Observer +from zona import util from zona.builder import ZonaBuilder from zona.log import get_logger from zona.websockets import WebSocketServer @@ -22,16 +23,16 @@ logger = get_logger() def make_reload_script(host: str, port: int) -> str: """Generates the JavaScript that must be injected into HTML pages for the live reloading to work.""" - return f""" - -""" + js = util.get_resource("server/inject.js").contents + js = util.minify_js(js) + address = f"ws://{host}:{port}" + for placeholder, value in (("__SOCKET_ADDRESS_", address),): + if placeholder not in js: + raise ValueError( + f"{placeholder} missing from reload script template!" + ) + js = js.replace(placeholder, value) + return f"" def make_handler_class(script: str): diff --git a/src/zona/util.py b/src/zona/util.py index 7e80500..2d5c514 100644 --- a/src/zona/util.py +++ b/src/zona/util.py @@ -1,4 +1,5 @@ import fnmatch +import re import string from importlib import resources from importlib.resources.abc import Traversable @@ -12,6 +13,15 @@ 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] = [] @@ -65,11 +75,28 @@ def normalize_url(url: str) -> str: return url -def should_ignore( - path: Path, patterns: list[str], base: Path -) -> bool: +def should_ignore(path: Path, patterns: list[str], base: Path) -> bool: rel_path = path.relative_to(base) return any( - fnmatch.fnmatch(str(rel_path), pattern) - for pattern in patterns + fnmatch.fnmatch(str(rel_path), pattern) for pattern in patterns ) + + +MINIFY_JS_PATTERN = re.compile( + r""" + //.*?$ | + /\*.*?\*/ | + \s+ + """, + re.MULTILINE | re.DOTALL | re.VERBOSE, +) + + +def minify_js(js: str) -> str: + """Naively minifies JavaScript by stripping comments and whitespace.""" + return MINIFY_JS_PATTERN.sub( + # replace whitespace with single space, + # strip comments + lambda m: " " if m.group(0).isspace() else "", + js, + ).strip() From fe0f3388034e939c125e30d8e808ccda7b9dcb4a Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sun, 13 Jul 2025 17:20:59 -0400 Subject: [PATCH 35/76] added auto-scrolling for preview --- src/zona/data/server/inject.js | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/zona/data/server/inject.js b/src/zona/data/server/inject.js index 550830f..d199c03 100644 --- a/src/zona/data/server/inject.js +++ b/src/zona/data/server/inject.js @@ -1,6 +1,24 @@ -const ws = new WebSocket("__SOCKET_ADDRESS_"); -ws.onmessage = event => { +(() => { + // 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_"); + ws.onmessage = event => { if (event.data === "reload") { - location.reload(); + // store flag if user currently at bottom + const nearBottom = window.innerHeight + window.scrollY + >= document.body.scrollHeight - 100; + if (nearBottom) { + localStorage.setItem("wasAtBottom", "1"); + } + location.reload(); } -}; + }; +})(); From 10d1772a2d7a14c977e8359e3df25a2a40948daa Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sun, 13 Jul 2025 17:31:25 -0400 Subject: [PATCH 36/76] added config option for preview scroll tolerance --- README.md | 328 +++++++++++++++++---------------- src/zona/cli.py | 19 +- src/zona/config.py | 12 ++ src/zona/data/server/inject.js | 5 +- src/zona/server.py | 28 ++- 5 files changed, 217 insertions(+), 175 deletions(-) diff --git a/README.md b/README.md index 9ae4ef4..df3c866 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,22 @@

zona

-[zona](https://sr.ht/~ficd/zona) is an _opinionated_ static site generator -written in Python. From a structured directory of Markdown content, zona -builds a simple static website. It's designed to get out of your way and -let you focus on writing. +[zona](https://git.ficd.sh/ficd/zona) is an _opinionated_ static site generator +written in Python. From a structured directory of Markdown content, zona builds +a simple static website. It's designed to get out of your way and let you focus +on writing. -**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. +**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. -**Note:** This project is in early development, there are no versioned -releases yet, and breaking changes are likely. Versioned releases will be -made and zona will be published to PyPI once it's stable. zona was -previously implemented in Go; I decided to rewrite the project in Python. -If you're interested in seeing the previous codebase (which is feature -incomplete), visit the [~ficd/zona-go](https://git.sr.ht/~ficd/zona-go) -repository. +**Note:** This project is in early development, there are no versioned releases +yet, and breaking changes are likely. Versioned releases will be made and zona +will be published to PyPI once it's stable. zona was previously implemented in +Go; I decided to rewrite the project in Python. If you're interested in seeing +the previous codebase (which is feature incomplete), visit the +[zona-go](https://git.ficd.sh/ficd/zona-go) repository. For an example of a website built with zona, please see [ficd.sh](https://ficd.sh). @@ -31,6 +29,7 @@ For an example of a website built with zona, please see - [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) @@ -60,15 +59,15 @@ For an example of a website built with zona, please see - 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). + - 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. + - Many `python-markdown` extensions enabled, including footnotes, tables, + abbreviations, etc. - LaTeX support. ## Installation @@ -77,7 +76,7 @@ zona is not yet packaged on PyPI. You may use `uv` to install it from this repository: ```sh -uv tool install 'git+https://git.sr.ht/~ficd/zona' +uv tool install 'git+https://git.ficd.sh/ficd/zona' ``` ## Usage @@ -87,71 +86,78 @@ 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. +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. +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. +`--output/-o` flag. The `--draft/-d` flag includes draft posts in the output. -_Note: the previous build is _not_ cleaned before the new site is built. -If you've deleted some pages, you may need to remove the output directory -before rebuilding._ +_Note: the previous build is _not_ cleaned before the new site is built. If +you've deleted some pages, you may need to remove the output directory before +rebuilding._ ### 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.) +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._ +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._ -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. +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. -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 `--no-live-reload/-n` disables the live browser reloading -(_automatic site rebuilds are not disabled_). +**Note**: if the live preview isn't working as expected, try restarting the +server. If you change the configuration or any templates, 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. -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. +#### Live Reload -**Note**: if the live preview isn't working as expected, try restarting -the server. If you change the configuration or any templates, 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. +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. +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.** +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 @@ -164,32 +170,30 @@ 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 prioritized if it exists. `public` is the -built site output — it's recommended to add this path to your -`.gitignore`. +The **root** of the zona **project** _must_ contain the configuration file, +`config.yml`, and a directory called `content`. A directory called `templates` +is optional, and prioritized if it exists. `public` is the built site output — +it's recommended to add this path to your `.gitignore`. The `content` directory is the **root of the website**. 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 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. +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. 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: +modify the existing templates or create your own. 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 | | ---------- | ------------------------------------------------------ | @@ -201,43 +205,40 @@ available to the template engine: #### 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.** +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.** ### 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. +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. +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: +Zona uses [Pygments] to provide syntax highlighting for fenced code blocks. The +following Pygments plugins are included: -- [pygments-kakoune](https://git.sr.ht/~ficd/pygments-kakoune) - - A lexer providing for highlighting Kakoune code. Available under the - `kak` and `kakrc` aliases. -- [pygments-ashen](https://git.sr.ht/~ficd/ashen/tree/main/item/pygments/README.md) - - An implementation of the [Ashen](https://git.sr.ht/~ficd/ashen) theme - for Pygments. +- [pygments-kakoune](https://codeberg.com/ficd/pygments-kakoune) + - A lexer providing for highlighting Kakoune code. Available under the `kak` + and `kakrc` aliases. +- [pygments-ashen](https://codeberg.com/ficd/ashen/tree/main/item/pygments/README.md) + - An implementation of the [Ashen](https://codeberg.com/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): +available in zona's Python environment. For example, you can give zona access to +[Catppucin](https://github.com/catppuccin/python): ```yaml # config.yml @@ -252,9 +253,9 @@ Then, run zona with the following `uv` command: 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: +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="")` @@ -280,10 +281,9 @@ will be rendered as ### 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: +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) @@ -298,14 +298,14 @@ The above results in the following HTML: ``` The `image-container` class is provided as a convenience for styling. The -default stylesheet centers the label under the image. Note: _links_ inside -image captions are not currently supported. I am looking into a solution. +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: +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 | | ------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------ | @@ -338,10 +338,9 @@ 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. +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: @@ -352,32 +351,32 @@ 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 +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`. +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. +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. +**Note:** Currently, not every configuration value is actually used. Only the +useful settings are listed here. Please see the default configuration: ```yaml +base_url: / sitemap: Home: / ignore: - .marksman.toml markdown: - image_labels: true tab_length: 2 syntax_highlighting: enabled: true @@ -385,49 +384,60 @@ markdown: wrap: false links: external_new_tab: true +build: + clean_output_dir: true + include_drafts: false blog: dir: blog +server: + reload: + enabled: true + scroll_tolerance: 100 ``` -| Name | Description | -| -------------------------------------- | --------------------------------------------------------------------------------------------- | -| `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. | -| `blog.dir` | Name of a directory relative to `content/` whose children are automatically considered posts. | +| Name | Description | +| -------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `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. | +| `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: +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.sr.ht/~ficd + 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. +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. +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://sr.ht/~ficd/ashen +[Ashen]: https://codeberg.com/ficd/ashen [Pygments]: https://pygments.org/ diff --git a/src/zona/cli.py b/src/zona/cli.py index 1d32c4c..a7e2dd5 100644 --- a/src/zona/cli.py +++ b/src/zona/cli.py @@ -95,14 +95,15 @@ def serve( bool, typer.Option("--final", "-f", help="Don't include drafts."), ] = False, - no_live_reload: Annotated[ - bool, + live_reload: Annotated[ + bool | None, typer.Option( - "--no-live-reload", - "-n", - help="Don't automatically reload web preview.", + "--live-reload/--no-live-reload", + "-l/-L", + help="Automatically reload web preview. Overrides config.", + show_default=False, ), - ] = False, + ] = None, ): """ Build the website and start a live preview server. @@ -115,13 +116,17 @@ def serve( 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, - live_reload=not no_live_reload, + user_reload=reload, ) diff --git a/src/zona/config.py b/src/zona/config.py index 4bd7a42..cc56ec2 100644 --- a/src/zona/config.py +++ b/src/zona/config.py @@ -58,6 +58,17 @@ 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) + + IGNORELIST = [".marksman.toml"] @@ -71,6 +82,7 @@ class ZonaConfig: markdown: MarkdownConfig = field(default_factory=MarkdownConfig) build: BuildConfig = field(default_factory=BuildConfig) blog: BlogConfig = field(default_factory=BlogConfig) + server: ServerConfig = field(default_factory=ServerConfig) @classmethod def from_file(cls, path: Path) -> "ZonaConfig": diff --git a/src/zona/data/server/inject.js b/src/zona/data/server/inject.js index d199c03..bc00dde 100644 --- a/src/zona/data/server/inject.js +++ b/src/zona/data/server/inject.js @@ -9,12 +9,13 @@ }); } - const ws = new WebSocket("__SOCKET_ADDRESS_"); + 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 - 100; + >= document.body.scrollHeight - tol; if (nearBottom) { localStorage.setItem("wasAtBottom", "1"); } diff --git a/src/zona/server.py b/src/zona/server.py index b2e0a64..23c4c0b 100644 --- a/src/zona/server.py +++ b/src/zona/server.py @@ -21,17 +21,22 @@ from zona.websockets import WebSocketServer logger = get_logger() -def make_reload_script(host: str, port: int) -> str: +def make_reload_script( + host: str, port: int, scroll_tolerance: int +) -> str: """Generates the JavaScript that must be injected into HTML pages for the live reloading to work.""" 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),): + 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, value) + js = js.replace(placeholder, str(value)) return f"" @@ -172,12 +177,13 @@ def serve( draft: bool = True, host: str = "localhost", port: int = 8000, - live_reload: bool = True, + 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 @@ -186,13 +192,21 @@ def serve( if root is None: root = builder.layout.root - # spin up websocket server for live reloading - if live_reload: + # 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 - reload_script = make_reload_script(host, ws_port) + scroll_tolerance = config.server.reload.scroll_tolerance + reload_script = make_reload_script( + host, ws_port, scroll_tolerance + ) # generate handler with reload script as attribute handler = make_handler_class(reload_script) else: From 0166e82cce3932f22fef5fa58f127dd09b08d79e Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sun, 13 Jul 2025 18:24:56 -0400 Subject: [PATCH 37/76] updated readme --- README.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index df3c866..1fbb3e4 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ the previous codebase (which is feature incomplete), visit the [zona-go](https://git.ficd.sh/ficd/zona-go) repository. For an example of a website built with zona, please see -[ficd.sh](https://ficd.sh). +[ficd.sh](https://ficd.sh). For a list of known problems, see +[Known Problems](#known-problems). @@ -44,6 +45,7 @@ For an example of a website built with zona, please see - [Sitemap](#sitemap) - [Ignore List](#ignore-list) - [Drafts](#drafts) +- [Known Problems](#known-problems) @@ -441,3 +443,13 @@ the `--draft` flag is specified. [Ashen]: https://codeberg.com/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. From 46934e502f242e47d890c5b1efc156b047946600 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sun, 13 Jul 2025 21:25:20 -0400 Subject: [PATCH 38/76] update readme --- README.md | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 1fbb3e4..dcccab2 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,6 @@ 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. -**Note:** This project is in early development, there are no versioned releases -yet, and breaking changes are likely. Versioned releases will be made and zona -will be published to PyPI once it's stable. zona was previously implemented in -Go; I decided to rewrite the project in Python. If you're interested in seeing -the previous codebase (which is feature incomplete), visit the -[zona-go](https://git.ficd.sh/ficd/zona-go) repository. - 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). @@ -74,11 +67,16 @@ For an example of a website built with zona, please see ## Installation -zona is not yet packaged on PyPI. You may use `uv` to install it from this -repository: +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 From a789d1f64ee1714f023f95b53785a425f73617d8 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sun, 13 Jul 2025 21:28:00 -0400 Subject: [PATCH 39/76] changed license to BSD-3 --- LICENSE | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/LICENSE b/LICENSE index ac101d3..1cbda58 100644 --- a/LICENSE +++ b/LICENSE @@ -1,13 +1,24 @@ -Copyright (c) 2025 Daniel Fichtinger +Copyright (c) 2025 Daniel Fichtinger -Permission to use, copy, modify, and distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the author nor the names of its contributors may + be used to endorse or promote products derived from this software -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. From 0e320241474f37c9e6afeecd576483b8a6c25e42 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sun, 13 Jul 2025 21:29:15 -0400 Subject: [PATCH 40/76] update metadata for release --- pyproject.toml | 11 ++++++++--- uv.lock | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index aea7a42..3af55b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,12 @@ [project] name = "zona" -version = "0.1.0" -description = "Static site generator" +version = "1.0.0" +description = "Opinionated static site generator." +license = "BSD-3" +license-files = ["LICENSE"] readme = "README.md" authors = [ - { name = "Daniel Fichtinger", email = "daniel@ficd.ca" }, + { name = "Daniel Fichtinger", email = "daniel@ficd.sh" }, ] requires-python = ">=3.12" dependencies = [ @@ -24,6 +26,9 @@ dependencies = [ "websockets>=15.0.1", ] +[project.urls] +Repository = "https://git.ficd.sh/ficd/zona" + [project.scripts] zona = "zona.cli:main" diff --git a/uv.lock b/uv.lock index 311053e..af06654 100644 --- a/uv.lock +++ b/uv.lock @@ -459,7 +459,7 @@ wheels = [ [[package]] name = "zona" -version = "0.1.0" +version = "1.0.0" source = { editable = "." } dependencies = [ { name = "dacite" }, From db8318622c350b1230c16be669c8534f44320813 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sun, 13 Jul 2025 21:32:24 -0400 Subject: [PATCH 41/76] fixed license identifier in pyproject --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3af55b6..caf20fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "zona" version = "1.0.0" description = "Opinionated static site generator." -license = "BSD-3" +license = "BSD-3-Clause " license-files = ["LICENSE"] readme = "README.md" authors = [ From e69dd808755c5ae6f853a8b7909d9786fb1c9dc8 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sun, 13 Jul 2025 21:33:36 -0400 Subject: [PATCH 42/76] updated just recipes --- justfile | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/justfile b/justfile index cfb1ed2..c0f2e14 100644 --- a/justfile +++ b/justfile @@ -2,6 +2,28 @@ default: @just --list +clean: + #!/bin/sh + if [ ! -d "dist" ] && [ ! -d "__pycache__" ]; then + echo "Nothing to clean." + exit 0 + fi + if [ -d "dist" ]; then + echo "Removing dist/" + rm -r dist/ + fi + if [ -d "__pycache__" ]; then + echo "Removing __pycache__/" + rm -r "__pycache__" + fi + +publish: + #!/bin/sh + just clean + uv build + export UV_PUBLISH_TOKEN="$(pass show pypi)" + uv publish + format: uv run ruff format From 8b849fd2a0e3c4087806b6acd6b401a53edd0560 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sun, 13 Jul 2025 21:59:53 -0400 Subject: [PATCH 43/76] ci: added pypi release workflow --- .forgejo/workflows/publish.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .forgejo/workflows/publish.yml diff --git a/.forgejo/workflows/publish.yml b/.forgejo/workflows/publish.yml new file mode 100644 index 0000000..7641a33 --- /dev/null +++ b/.forgejo/workflows/publish.yml @@ -0,0 +1,22 @@ +on: + push: + tags: + - 'v*' +jobs: + publish: + runs-on: docker + container: + image: node:alpine + steps: + - name: Install dependencies + run: | + apk add --no-cache coreutils uv + - uses: actions/checkout@v4 + - name: build + run: | + uv sync + uv build + - name: publish + run: | + uv publish --token ${{ secrets.PYPI_TOKEN }} + From e28a206067b17d52f678adb1c9d33a8b7dd55f77 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sun, 13 Jul 2025 23:42:51 -0400 Subject: [PATCH 44/76] update publish workflow to use based-alpine --- .forgejo/workflows/publish.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.forgejo/workflows/publish.yml b/.forgejo/workflows/publish.yml index 7641a33..53dd92f 100644 --- a/.forgejo/workflows/publish.yml +++ b/.forgejo/workflows/publish.yml @@ -4,13 +4,8 @@ on: - 'v*' jobs: publish: - runs-on: docker - container: - image: node:alpine + runs-on: based-alpine steps: - - name: Install dependencies - run: | - apk add --no-cache coreutils uv - uses: actions/checkout@v4 - name: build run: | From faac8f63bbf960bb41fb99924db46533cd7c203d Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Mon, 14 Jul 2025 15:00:54 -0400 Subject: [PATCH 45/76] major updates to default stylesheet --- src/zona/data/content/static/style.css | 67 +++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/src/zona/data/content/static/style.css b/src/zona/data/content/static/style.css index 284a5ce..56bbae9 100644 --- a/src/zona/data/content/static/style.css +++ b/src/zona/data/content/static/style.css @@ -1,14 +1,17 @@ :root { --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); } body { + margin: 0; line-height: 1.6; font-size: 18px; font-family: sans-serif; @@ -17,6 +20,20 @@ body { padding-left: calc(100vw - 100%); } +header { + padding-top: -1rem; + margin-top: -1rem; + font-family: monospace; + text-transform: lowercase; +} + +.site-logo { + color: inherit; + font-weight: bold; + text-decoration: none; + /* font-size: 1.75rem;*/ +} + .toclink { position: relative; text-decoration: none; @@ -128,11 +145,28 @@ li::marker { a { color: var(--main-link-color); - text-decoration: underline; + text-decoration: none; + position: relative; } -a:hover { - background: var(--main-transparent); +a::after { + content: ""; + position: absolute; + left: 0; + bottom: -2px; + width: 100%; + height: 1px; + background-color: currentColor; + transform: scaleX(0); + transform-origin: center; + transition: transform 0.1s ease; +} + +a:hover::after { + transform: scaleX(1); +} +a:has(> code)::after { + display: none; } max-width: 100%; @@ -146,8 +180,8 @@ img { } blockquote { - color: var(--main-small-text-color); - border-left: 3px solid var(--main-transparent); + color: var(--main-text-opaque-color); + border-left: 3px solid var(--orange-rgb); padding: 0 1rem; margin-left: 0; margin-right: 0; @@ -157,6 +191,16 @@ 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 { @@ -183,7 +227,7 @@ pre { } pre { - background-color: #151515; + background-color: #1d1d1d; color: #d5d5d5; padding: 1em; border-radius: 5px; @@ -362,13 +406,20 @@ caption { a > code { text-decoration: none; - color: inherit; + color: var(--main-link-color); + position: relative; } a:has(> code) { text-decoration: none; + background: none; + /* position: static;*/ } a:hover > code { - background-color: var(--main-transparent); + text-decoration: underline; +} + +a:hover:has(> code) { + background: none; } From 04948a93739f1d80c53cf4d63882733e19bb597b Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Mon, 14 Jul 2025 16:04:05 -0400 Subject: [PATCH 46/76] formatted python files --- src/zona/builder.py | 20 ++++++++++++++------ src/zona/cli.py | 8 ++++++-- src/zona/config.py | 4 +++- src/zona/server.py | 4 +++- src/zona/util.py | 11 ++++++++--- 5 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/zona/builder.py b/src/zona/builder.py index 12b5458..a04b2eb 100644 --- a/src/zona/builder.py +++ b/src/zona/builder.py @@ -48,7 +48,9 @@ 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}.") @@ -69,11 +71,13 @@ class ZonaBuilder: item.copy = False name = destination.stem if name == "index": - item.destination = item.destination.with_suffix( - ".html" + item.destination = ( + item.destination.with_suffix(".html") ) else: - relative = path.relative_to(base).with_suffix("") + relative = path.relative_to(base).with_suffix( + "" + ) name = relative.stem item.destination = ( layout.output @@ -85,7 +89,9 @@ class ZonaBuilder: layout.output ) item.url = ( - "" if rel_url == Path(".") else rel_url.as_posix() + "" + if rel_url == Path(".") + else rel_url.as_posix() ) items.append(item) self.items = items @@ -111,7 +117,9 @@ class ZonaBuilder: # write code highlighting stylesheet if self.config.markdown.syntax_highlighting.enabled: pygments_style = zmd.get_style_defs(self.config) - pygments_path = self.layout.output / "static" / "pygments.css" + pygments_path = ( + self.layout.output / "static" / "pygments.css" + ) util.ensure_parents(pygments_path) pygments_path.write_text(pygments_style) for item in self.item_map.values(): diff --git a/src/zona/cli.py b/src/zona/cli.py index a7e2dd5..4b48d3a 100644 --- a/src/zona/cli.py +++ b/src/zona/cli.py @@ -59,7 +59,9 @@ def build( """ if draft: print("Option override: including drafts.") - builder = ZonaBuilder(cli_root=root, cli_output=output, draft=draft) + builder = ZonaBuilder( + cli_root=root, cli_output=output, draft=draft + ) builder.build() @@ -73,7 +75,9 @@ def serve( ] = None, host: Annotated[ str, - typer.Option("--host", help="Hostname for live preview server."), + typer.Option( + "--host", help="Hostname for live preview server." + ), ] = "localhost", port: Annotated[ int, diff --git a/src/zona/config.py b/src/zona/config.py index cc56ec2..2a50fd4 100644 --- a/src/zona/config.py +++ b/src/zona/config.py @@ -76,7 +76,9 @@ IGNORELIST = [".marksman.toml"] class ZonaConfig: base_url: str = "/" # dictionary where key is name, value is url - sitemap: SitemapConfig = field(default_factory=lambda: {"Home": "/"}) + sitemap: SitemapConfig = field( + default_factory=lambda: {"Home": "/"} + ) # list of globs relative to content that should be ignored ignore: list[str] = field(default_factory=lambda: IGNORELIST) markdown: MarkdownConfig = field(default_factory=MarkdownConfig) diff --git a/src/zona/server.py b/src/zona/server.py index 23c4c0b..82e6381 100644 --- a/src/zona/server.py +++ b/src/zona/server.py @@ -238,7 +238,9 @@ def serve( event_handler, path=str(root / "content"), recursive=True ) observer.schedule( - event_handler, path=str(root / "templates"), recursive=True + event_handler, + path=str(root / "templates"), + recursive=True, ) observer.start() diff --git a/src/zona/util.py b/src/zona/util.py index 2d5c514..9ccc2c6 100644 --- a/src/zona/util.py +++ b/src/zona/util.py @@ -19,7 +19,9 @@ def get_resource(path: str) -> ZonaResource: if file.is_file(): return ZonaResource(name=path, contents=file.read_text()) else: - raise FileNotFoundError(f"{path} is not a valid Zona resource!") + raise FileNotFoundError( + f"{path} is not a valid Zona resource!" + ) def get_resources(subdir: str) -> list[ZonaResource]: @@ -75,10 +77,13 @@ def normalize_url(url: str) -> str: return url -def should_ignore(path: Path, patterns: list[str], base: Path) -> bool: +def should_ignore( + path: Path, patterns: list[str], base: Path +) -> bool: rel_path = path.relative_to(base) return any( - fnmatch.fnmatch(str(rel_path), pattern) for pattern in patterns + fnmatch.fnmatch(str(rel_path), pattern) + for pattern in patterns ) From 27178dc6d8c0f43dfa9da56db8e9d772bc99ffa0 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Mon, 14 Jul 2025 16:06:24 -0400 Subject: [PATCH 47/76] updated formatter config --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index caf20fb..2a2e6b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,7 @@ allowedUntypedLibraries = ["frontmatter", "pygments", "pymdownx", "l2m4m"] [tool.ruff] line-length = 70 indent-width = 4 -target-version = "py311" +target-version = "py312" [tool.ruff.lint] fixable = ["ALL"] From 07152a37468309a57e9fef6698f5bed65807e4bd Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Mon, 14 Jul 2025 16:11:47 -0400 Subject: [PATCH 48/76] feat: ignore frontmatter option --- README.md | 1 + src/zona/builder.py | 22 +++++++--------------- src/zona/metadata.py | 1 + 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index dcccab2..d46a69b 100644 --- a/README.md +++ b/README.md @@ -317,6 +317,7 @@ available: | `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 diff --git a/src/zona/builder.py b/src/zona/builder.py index a04b2eb..446f07d 100644 --- a/src/zona/builder.py +++ b/src/zona/builder.py @@ -48,14 +48,12 @@ 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) - if ( + if item.metadata.ignore or ( item.metadata.draft and not self.config.build.include_drafts ): @@ -71,13 +69,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 @@ -89,9 +85,7 @@ class ZonaBuilder: layout.output ) item.url = ( - "" - if rel_url == Path(".") - else rel_url.as_posix() + "" if rel_url == Path(".") else rel_url.as_posix() ) items.append(item) self.items = items @@ -117,9 +111,7 @@ class ZonaBuilder: # write code highlighting stylesheet if self.config.markdown.syntax_highlighting.enabled: pygments_style = zmd.get_style_defs(self.config) - pygments_path = ( - self.layout.output / "static" / "pygments.css" - ) + pygments_path = self.layout.output / "static" / "pygments.css" util.ensure_parents(pygments_path) pygments_path.write_text(pygments_style) for item in self.item_map.values(): diff --git a/src/zona/metadata.py b/src/zona/metadata.py index b079266..0cd6a8c 100644 --- a/src/zona/metadata.py +++ b/src/zona/metadata.py @@ -24,6 +24,7 @@ class Metadata: template: str | None = None post: bool | None = None draft: bool = False + ignore: bool = False math: bool = True From 2bc12ff2edc7b4d23320b799d7e0143e996e50d6 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Mon, 14 Jul 2025 16:24:14 -0400 Subject: [PATCH 49/76] refactored title in templates now has "title" class for easier styling --- src/zona/data/templates/basic.html | 4 +--- src/zona/data/templates/page.html | 16 ++++++---------- src/zona/data/templates/post_list.html | 24 +++++++++++------------- src/zona/data/templates/title.html | 4 ++++ 4 files changed, 22 insertions(+), 26 deletions(-) create mode 100644 src/zona/data/templates/title.html diff --git a/src/zona/data/templates/basic.html b/src/zona/data/templates/basic.html index 033d9e3..caddc62 100644 --- a/src/zona/data/templates/basic.html +++ b/src/zona/data/templates/basic.html @@ -1,9 +1,7 @@ {% extends "base.html" %} {% block content %} -{% if metadata.show_title %} -

{{ metadata.title }}

-{% endif %} +{% include "title.html" %} {{ content | safe }} {% endblock %} diff --git a/src/zona/data/templates/page.html b/src/zona/data/templates/page.html index 3e835b4..96250f1 100644 --- a/src/zona/data/templates/page.html +++ b/src/zona/data/templates/page.html @@ -1,13 +1,9 @@ -{% extends "base.html" %} - -{% block content %} -{% if metadata.show_title %} -

{{ metadata.title }}

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

{{ metadata.title }}

+{% extends "base.html" %} {% block content %} {% include "title.html" %}
{{ content | safe }}
{% if post_list %} - -{% endif %} -{% endblock %} - + +{% endif %} {% endblock %} diff --git a/src/zona/data/templates/title.html b/src/zona/data/templates/title.html new file mode 100644 index 0000000..246f83b --- /dev/null +++ b/src/zona/data/templates/title.html @@ -0,0 +1,4 @@ +{% if metadata.show_title %} +

{{ metadata.title }}

+
+{% endif %} From de86a92928c77570b104318d7baccd85877c894c Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Mon, 14 Jul 2025 16:24:28 -0400 Subject: [PATCH 50/76] added title styling to default stylesheet --- src/zona/data/content/static/style.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/zona/data/content/static/style.css b/src/zona/data/content/static/style.css index 56bbae9..9e5633e 100644 --- a/src/zona/data/content/static/style.css +++ b/src/zona/data/content/static/style.css @@ -90,6 +90,11 @@ h1 { font-weight: bold; } +.title { + text-transform: lowercase; + font-family: monospace; +} + article h1:first-of-type { margin-block-start: 1.67rem; } From 24919171ad4cdedc30368c6f17ce8584bcbf512d Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Mon, 14 Jul 2025 16:34:17 -0400 Subject: [PATCH 51/76] fixed structure of page date in templates --- src/zona/data/templates/basic.html | 2 ++ src/zona/data/templates/page.html | 13 +++++++++++-- src/zona/data/templates/post_list.html | 7 ++++++- src/zona/data/templates/title.html | 4 +--- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/zona/data/templates/basic.html b/src/zona/data/templates/basic.html index caddc62..d86a25a 100644 --- a/src/zona/data/templates/basic.html +++ b/src/zona/data/templates/basic.html @@ -1,7 +1,9 @@ {% extends "base.html" %} {% block content %} +{% if metadata.show_title %} {% include "title.html" %} +{% endif %} {{ content | safe }} {% endblock %} diff --git a/src/zona/data/templates/page.html b/src/zona/data/templates/page.html index 96250f1..fd3cdb4 100644 --- a/src/zona/data/templates/page.html +++ b/src/zona/data/templates/page.html @@ -1,9 +1,18 @@ -{% extends "base.html" %} {% block content %} {% include "title.html" %} {% if -metadata.date %} +{% extends "base.html" %} {% block content %} + +{% if metadata.show_title %} +{% include "title.html" %} +{% if metadata.date %}
{% endif %} +
+{% endif %} +
{{ content | safe }}
{% endblock %} + + + diff --git a/src/zona/data/templates/post_list.html b/src/zona/data/templates/post_list.html index 8c849ee..85e0be8 100644 --- a/src/zona/data/templates/post_list.html +++ b/src/zona/data/templates/post_list.html @@ -1,4 +1,8 @@ -{% extends "base.html" %} {% block content %} {% include "title.html" %} +{% extends "base.html" %} {% block content %} + +{% if metadata.show_title %} +{% include "title.html" %} +{% endif %}
{{ content | safe }}
@@ -13,3 +17,4 @@ {% endfor %} {% endif %} {% endblock %} + diff --git a/src/zona/data/templates/title.html b/src/zona/data/templates/title.html index 246f83b..9753365 100644 --- a/src/zona/data/templates/title.html +++ b/src/zona/data/templates/title.html @@ -1,4 +1,2 @@ -{% if metadata.show_title %}

{{ metadata.title }}

-
-{% endif %} + From 404e951651f4192e6828078a725a51e257e9ebbe Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Mon, 14 Jul 2025 18:57:51 -0400 Subject: [PATCH 52/76] added smooth scroll to default stylesheet --- src/zona/data/content/static/style.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/zona/data/content/static/style.css b/src/zona/data/content/static/style.css index 9e5633e..d2cacf0 100644 --- a/src/zona/data/content/static/style.css +++ b/src/zona/data/content/static/style.css @@ -10,6 +10,10 @@ --main-small-text-color: rgba(255, 255, 255, 0.45); } +html { + scroll-behavior: smooth; +} + body { margin: 0; line-height: 1.6; From 933210c93b6ea9ad1f7b1d20099fb6784859e7b9 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Mon, 14 Jul 2025 19:19:58 -0400 Subject: [PATCH 53/76] fixed crash on missing templates dir when starting server --- src/zona/server.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/zona/server.py b/src/zona/server.py index 82e6381..f8b0000 100644 --- a/src/zona/server.py +++ b/src/zona/server.py @@ -237,11 +237,13 @@ def serve( observer.schedule( event_handler, path=str(root / "content"), recursive=True ) - observer.schedule( - event_handler, - path=str(root / "templates"), - recursive=True, - ) + templates = root / "templates" + if templates.is_dir(): + observer.schedule( + event_handler, + path=str(templates), + recursive=True, + ) observer.start() # function to shut down gracefully From c489a6f0763c33e3725adba96dbc728f84b529ad Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Mon, 14 Jul 2025 20:15:40 -0400 Subject: [PATCH 54/76] added post-nav to posts --- src/zona/builder.py | 7 +++++++ src/zona/data/content/static/style.css | 12 ++++++++++++ src/zona/data/templates/page.html | 14 ++++---------- src/zona/data/templates/post_nav.html | 9 +++++++++ src/zona/metadata.py | 2 ++ src/zona/models.py | 4 ++++ src/zona/templates.py | 6 ++++++ 7 files changed, 44 insertions(+), 10 deletions(-) create mode 100644 src/zona/data/templates/post_nav.html diff --git a/src/zona/builder.py b/src/zona/builder.py index 446f07d..c53701e 100644 --- a/src/zona/builder.py +++ b/src/zona/builder.py @@ -99,6 +99,13 @@ class ZonaBuilder: else date.min, reverse=True, ) + posts = len(post_list) + for i, item in enumerate(post_list): + prev = post_list[i - 1] if i > 0 else None + next = post_list[i + 1] if i < posts - 2 else None + item.previous = prev + item.next = next + templater = Templater( config=self.config, template_dir=self.layout.templates, diff --git a/src/zona/data/content/static/style.css b/src/zona/data/content/static/style.css index d2cacf0..f02717e 100644 --- a/src/zona/data/content/static/style.css +++ b/src/zona/data/content/static/style.css @@ -31,6 +31,18 @@ header { text-transform: lowercase; } +.post-nav { + font-family: monospace; +} + +.post-nav .symbol { + color: var(--main-bullet-color); +} + +.post-nav a { + margin: 0 2px; +} + .site-logo { color: inherit; font-weight: bold; diff --git a/src/zona/data/templates/page.html b/src/zona/data/templates/page.html index fd3cdb4..0c22f8b 100644 --- a/src/zona/data/templates/page.html +++ b/src/zona/data/templates/page.html @@ -1,18 +1,12 @@ -{% extends "base.html" %} {% block content %} - -{% if metadata.show_title %} -{% include "title.html" %} -{% if metadata.date %} +{% extends "base.html" %} {% block content %} {% if metadata.show_title %} {% +include "title.html" %} {% if metadata.date %}
-{% endif %} +{% endif %} {% endif %} {% if is_post %} {% include "post_nav.html" %} {% endif +%}
-{% endif %}
{{ content | safe }}
{% endblock %} - - - diff --git a/src/zona/data/templates/post_nav.html b/src/zona/data/templates/post_nav.html new file mode 100644 index 0000000..324f110 --- /dev/null +++ b/src/zona/data/templates/post_nav.html @@ -0,0 +1,9 @@ +
+
+ <{% if previous %}prev{% endif %}{% if previous and next %}|{% endif %}{% if next %}next{% endif + %}> +
+
diff --git a/src/zona/metadata.py b/src/zona/metadata.py index 0cd6a8c..98b14cf 100644 --- a/src/zona/metadata.py +++ b/src/zona/metadata.py @@ -18,6 +18,8 @@ class Metadata: date: date description: str | None show_title: bool = True + show_date: bool = True + show_nav: bool = True style: str | None = "/static/style.css" header: bool = True footer: bool = True diff --git a/src/zona/models.py b/src/zona/models.py index 2e7721c..d009476 100644 --- a/src/zona/models.py +++ b/src/zona/models.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from dataclasses import dataclass from enum import Enum from pathlib import Path @@ -21,6 +23,8 @@ class Item: type: ItemType | None = None copy: bool = True post: bool = False + next: Item | None = None + previous: Item | None = None # @dataclass diff --git a/src/zona/templates.py b/src/zona/templates.py index 872762c..fe1e6dd 100644 --- a/src/zona/templates.py +++ b/src/zona/templates.py @@ -3,6 +3,7 @@ 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 zona.models import Item @@ -76,5 +77,10 @@ class Templater: metadata=meta, header=header, footer=footer, + is_post=item.post, + next=util.normalize_url(item.next.url) if item.next else None, + previous=util.normalize_url(item.previous.url) + if item.previous + else None, post_list=self.post_list, ) From 1f2b7368156fb2ec6e2f2c2bb1523181e969013c Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Mon, 14 Jul 2025 21:20:50 -0400 Subject: [PATCH 55/76] feat: merge user templates with defaults --- README.md | 11 ++++++----- src/zona/builder.py | 24 ++++++++++++++++++------ src/zona/layout.py | 41 +++++++++++++++++++++++++++++++++++++++-- src/zona/templates.py | 5 ++++- src/zona/util.py | 36 +++++++++++++++++++++++++++++++++++- 5 files changed, 102 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index d46a69b..bd7eb9b 100644 --- a/README.md +++ b/README.md @@ -172,8 +172,8 @@ 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 prioritized if it exists. `public` is the built site output — -it's recommended to add this path to your `.gitignore`. +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`. @@ -191,9 +191,10 @@ 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. 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: +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 | | ---------- | ------------------------------------------------------ | diff --git a/src/zona/builder.py b/src/zona/builder.py index c53701e..5c8af6b 100644 --- a/src/zona/builder.py +++ b/src/zona/builder.py @@ -30,6 +30,7 @@ class ZonaBuilder: self.config.build.include_drafts = True self.items: list[Item] = [] self.item_map: dict[Path, Item] = {} + self.fresh: bool = True def _discover(self): layout = self.layout @@ -48,7 +49,9 @@ 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}.") @@ -69,11 +72,13 @@ class ZonaBuilder: item.copy = False name = destination.stem if name == "index": - item.destination = item.destination.with_suffix( - ".html" + item.destination = ( + item.destination.with_suffix(".html") ) else: - relative = path.relative_to(base).with_suffix("") + relative = path.relative_to(base).with_suffix( + "" + ) name = relative.stem item.destination = ( layout.output @@ -85,7 +90,9 @@ class ZonaBuilder: layout.output ) item.url = ( - "" if rel_url == Path(".") else rel_url.as_posix() + "" + if rel_url == Path(".") + else rel_url.as_posix() ) items.append(item) self.items = items @@ -118,7 +125,9 @@ class ZonaBuilder: # write code highlighting stylesheet if self.config.markdown.syntax_highlighting.enabled: pygments_style = zmd.get_style_defs(self.config) - pygments_path = self.layout.output / "static" / "pygments.css" + pygments_path = ( + self.layout.output / "static" / "pygments.css" + ) util.ensure_parents(pygments_path) pygments_path.write_text(pygments_style) for item in self.item_map.values(): @@ -159,7 +168,10 @@ class ZonaBuilder: 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() + self.fresh = False diff --git a/src/zona/layout.py b/src/zona/layout.py index 3526c4e..3d8de3d 100644 --- a/src/zona/layout.py +++ b/src/zona/layout.py @@ -16,6 +16,22 @@ class Layout: content: Path templates: Path output: Path + shared_templates: util.TempDir | None + _validate: bool + + def refresh(self): + logger.debug("Refreshing layout...") + if ( + self.shared_templates + and not self.shared_templates.removed + ): + logger.debug("Removing stale templates tempdir...") + self.shared_templates.remove() + return self.__class__.from_input( + root=self.root, + output=self.output, + validate=self._validate, + ) @classmethod def from_input( @@ -31,6 +47,8 @@ class Layout: output=(root / "public").resolve() if not output else output, + shared_templates=None, + _validate=validate, ) if validate: logger.debug("Validating site layout...") @@ -39,10 +57,29 @@ class Layout: raise FileNotFoundError( "Missing required content directory!" ) - if not layout.templates.is_dir(): + internal_templates = util.get_resource_dir("templates") + user_templates = layout.templates + if not user_templates.is_dir() or util.is_empty( + user_templates + ): logger.debug("Using default template directory.") # use the included defaults - layout.templates = util.get_resource_dir("templates") + layout.templates = internal_templates + else: + seen: set[str] = set() + temp = util.TempDir() + logger.debug( + f"Creating shared template directory at {temp}" + ) + for f in user_templates.iterdir(): + if f.is_file(): + util.copy_static_file(f, temp.path) + seen.add(f.name) + for f in internal_templates.iterdir(): + if f.is_file() and f.name not in seen: + util.copy_static_file(f, temp.path) + layout.shared_templates = temp + layout.templates = temp.path return layout diff --git a/src/zona/templates.py b/src/zona/templates.py index fe1e6dd..d5e0f22 100644 --- a/src/zona/templates.py +++ b/src/zona/templates.py @@ -36,6 +36,7 @@ class Templater: template_dir: Path, post_list: list[Item], ): + # build temporary template dir self.env: Environment = Environment( loader=FileSystemLoader(template_dir), autoescape=select_autoescape(["html", "xml"]), @@ -78,7 +79,9 @@ class Templater: header=header, footer=footer, is_post=item.post, - next=util.normalize_url(item.next.url) if item.next else None, + next=util.normalize_url(item.next.url) + if item.next + else None, previous=util.normalize_url(item.previous.url) if item.previous else None, diff --git a/src/zona/util.py b/src/zona/util.py index 9ccc2c6..5bf6f53 100644 --- a/src/zona/util.py +++ b/src/zona/util.py @@ -1,11 +1,36 @@ import fnmatch import re +import shutil import string +import tempfile +import weakref from importlib import resources from importlib.resources.abc import Traversable from pathlib import Path from shutil import copy2 -from typing import NamedTuple +from typing import Any, NamedTuple, override + + +class TempDir: + """Temporary directory that cleans up when it's garbage collected.""" + + def __init__(self): + self._tempdir: str = tempfile.mkdtemp() + self.path: Path = Path(self._tempdir) + self._finalizer: weakref.finalize[Any, Any] = ( + weakref.finalize(self, shutil.rmtree, self._tempdir) + ) + + def remove(self): + self._finalizer() + + @property + def removed(self): + return not self._finalizer.alive + + @override + def __repr__(self) -> str: + return f"" class ZonaResource(NamedTuple): @@ -65,6 +90,15 @@ def copy_static_file(src: Path, dst: Path): copy2(src, dst) +def is_empty(path: Path) -> bool: + """If given a file, check if it has any non-whitespace content. + If given a directory, check if it has any children.""" + if path.is_file(): + return path.read_text().strip() == "" + else: + return not any(path.iterdir()) + + def filename_to_title(path: Path) -> str: name = path.stem words = name.replace("-", " ").replace("_", " ") From 115751b1207ad7e3df0ca65de6bc60a7845f2920 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Mon, 14 Jul 2025 22:50:26 -0400 Subject: [PATCH 56/76] fix: mistake in post-nav template --- src/zona/data/templates/post_nav.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/zona/data/templates/post_nav.html b/src/zona/data/templates/post_nav.html index 324f110..86ac330 100644 --- a/src/zona/data/templates/post_nav.html +++ b/src/zona/data/templates/post_nav.html @@ -1,9 +1,9 @@
<{% if previous %}prev{% endif %}{% if previous and next %}|{% endif %}{% if next %}next{% endif + >|{% endif %}{% if next %}next{% endif %}>
From f72142fff385dc6385123ed8a2ce0de4e95d9941 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Mon, 14 Jul 2025 22:51:46 -0400 Subject: [PATCH 57/76] prepare release --- CHANGELOG.md | 15 +++++++++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6e65c79 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# 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/pyproject.toml b/pyproject.toml index 2a2e6b4..6b517bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "zona" -version = "1.0.0" +version = "1.1.0" description = "Opinionated static site generator." license = "BSD-3-Clause " license-files = ["LICENSE"] diff --git a/uv.lock b/uv.lock index af06654..c1a3f41 100644 --- a/uv.lock +++ b/uv.lock @@ -459,7 +459,7 @@ wheels = [ [[package]] name = "zona" -version = "1.0.0" +version = "1.1.0" source = { editable = "." } dependencies = [ { name = "dacite" }, From c6dd2785af8b7b68b50cb6e9f245b660e3258e99 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Tue, 15 Jul 2025 15:09:23 -0400 Subject: [PATCH 58/76] added .kakrc to manage cwd in editor --- .kakrc | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .kakrc diff --git a/.kakrc b/.kakrc new file mode 100644 index 0000000..7a60b1f --- /dev/null +++ b/.kakrc @@ -0,0 +1,21 @@ +# 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 +} + +# change working directory to the package +hook global -once BufCreate .* %{ + change-directory %exp{%opt{project_root}/src/zona} +} From fa10a813f214af66622025044e412735cbe4729d Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Tue, 15 Jul 2025 16:48:03 -0400 Subject: [PATCH 59/76] add to .kakrc --- .kakrc | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.kakrc b/.kakrc index 7a60b1f..688bcbe 100644 --- a/.kakrc +++ b/.kakrc @@ -15,6 +15,10 @@ 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} From bafe70ed3728682af41597e908b7b5ad97ef496b Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Tue, 15 Jul 2025 16:52:58 -0400 Subject: [PATCH 60/76] fix post-nav button order Navigation now follows newer/older logic. click right to go older, left to go newer. --- src/zona/builder.py | 14 ++++++++++---- src/zona/data/templates/post_nav.html | 12 +++++++----- src/zona/models.py | 4 ++-- src/zona/templates.py | 9 ++++----- 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/src/zona/builder.py b/src/zona/builder.py index 5c8af6b..416edc8 100644 --- a/src/zona/builder.py +++ b/src/zona/builder.py @@ -99,6 +99,8 @@ class ZonaBuilder: 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 @@ -106,12 +108,16 @@ class ZonaBuilder: else date.min, reverse=True, ) + # number of posts posts = len(post_list) + # link post chronology for i, item in enumerate(post_list): - prev = post_list[i - 1] if i > 0 else None - next = post_list[i + 1] if i < posts - 2 else None - item.previous = prev - item.next = next + # 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, diff --git a/src/zona/data/templates/post_nav.html b/src/zona/data/templates/post_nav.html index 86ac330..1e96ff5 100644 --- a/src/zona/data/templates/post_nav.html +++ b/src/zona/data/templates/post_nav.html @@ -1,9 +1,11 @@
- <{% if previous %}prev{% endif %}{% if previous and next %}|{% endif %}{% if next %}next{% endif - %}> + <{% if newer %}newr{% + else %}null{% endif %}{% if older %}oldr{% else %}null{% endif %}>
diff --git a/src/zona/models.py b/src/zona/models.py index d009476..7f67015 100644 --- a/src/zona/models.py +++ b/src/zona/models.py @@ -23,8 +23,8 @@ class Item: type: ItemType | None = None copy: bool = True post: bool = False - next: Item | None = None - previous: Item | None = None + newer: Item | None = None + older: Item | None = None # @dataclass diff --git a/src/zona/templates.py b/src/zona/templates.py index d5e0f22..4529143 100644 --- a/src/zona/templates.py +++ b/src/zona/templates.py @@ -27,7 +27,6 @@ def get_footer(template_dir: Path) -> str | None: return html_footer.read_text() -# TODO: add next/prev post button logic to posts # TODO: add a recent posts element that can be included elsewhere? class Templater: def __init__( @@ -79,11 +78,11 @@ class Templater: header=header, footer=footer, is_post=item.post, - next=util.normalize_url(item.next.url) - if item.next + newer=util.normalize_url(item.newer.url) + if item.newer else None, - previous=util.normalize_url(item.previous.url) - if item.previous + older=util.normalize_url(item.older.url) + if item.older else None, post_list=self.post_list, ) From c33acfa1c86e4ca37ab4ba7d87630b35014e53ce Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Tue, 15 Jul 2025 17:52:49 -0400 Subject: [PATCH 61/76] added hover symbols to page titles --- src/zona/data/content/static/style.css | 109 ++++++++++++++++++++----- src/zona/data/templates/title.html | 2 +- 2 files changed, 88 insertions(+), 23 deletions(-) diff --git a/src/zona/data/content/static/style.css b/src/zona/data/content/static/style.css index f02717e..cd78efa 100644 --- a/src/zona/data/content/static/style.css +++ b/src/zona/data/content/static/style.css @@ -1,4 +1,5 @@ :root { + --main-placeholder-color: #b14242; --main-text-color: #b4b4b4; --main-text-opaque-color: rgba(180, 180, 180, 0.8); --main-bg-color: #121212; @@ -33,21 +34,65 @@ header { .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; } -.post-nav a { - margin: 0 2px; +.site-logo.hover-symbol::before { + content: "@"; } -.site-logo { +.title.hover-symbol::before { + content: ">"; + color: var(--main-bullet-color); +} + +.hover-symbol { color: inherit; + position: relative; font-weight: bold; text-decoration: none; - /* font-size: 1.75rem;*/ +} + +.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.2s ease; +} + +.hover-symbol:hover::before { + opacity: 1; +} +.hover-symbol:hover { + background-color: transparent; } .toclink { @@ -111,6 +156,11 @@ h1 { font-family: monospace; } +.title a { + color: inherit; + text-decoration: none; +} + article h1:first-of-type { margin-block-start: 1.67rem; } @@ -164,30 +214,45 @@ li::marker { /* Change this to your desired color */ } +/*a {*/ +/* color: var(--main-link-color);*/ +/* text-decoration: none;*/ +/* position: relative;*/ +/*}*/ + +/*a::after {*/ +/* content: "";*/ +/* position: absolute;*/ +/* left: 0;*/ +/* bottom: -2px;*/ +/* width: 100%;*/ +/* height: 1px;*/ +/* background-color: currentColor;*/ +/* transform: scaleX(0);*/ +/* transform-origin: center;*/ +/* transition: transform 0.1s ease;*/ +/*}*/ + +/*a:hover::after {*/ +/* transform: scaleX(1);*/ +/*}*/ +/*a:has(> code)::after {*/ +/* display: none;*/ +/*}*/ + a { color: var(--main-link-color); - text-decoration: none; - position: relative; + text-decoration: underline; + text-decoration-color: rgba(0, 0, 0, 0); + text-underline-offset: 2px; /* Optional: tweak spacing */ } -a::after { - content: ""; - position: absolute; - left: 0; - bottom: -2px; - width: 100%; - height: 1px; - background-color: currentColor; - transform: scaleX(0); - transform-origin: center; - transition: transform 0.1s ease; +a { + transition: text-decoration-color 0.15s ease; } -a:hover::after { - transform: scaleX(1); -} -a:has(> code)::after { - display: none; +a:hover { + text-decoration-color: currentColor; } max-width: 100%; diff --git a/src/zona/data/templates/title.html b/src/zona/data/templates/title.html index 9753365..1541095 100644 --- a/src/zona/data/templates/title.html +++ b/src/zona/data/templates/title.html @@ -1,2 +1,2 @@ -

{{ metadata.title }}

+

{{ metadata.title }}

From 4ea80a33f854d8a58f79abbefdcedd29f48ff635 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Tue, 15 Jul 2025 18:35:13 -0400 Subject: [PATCH 62/76] improved styling of hover symbols --- src/zona/data/content/static/style.css | 83 +++++++------------------- 1 file changed, 22 insertions(+), 61 deletions(-) diff --git a/src/zona/data/content/static/style.css b/src/zona/data/content/static/style.css index cd78efa..56644c3 100644 --- a/src/zona/data/content/static/style.css +++ b/src/zona/data/content/static/style.css @@ -62,11 +62,12 @@ header { .site-logo.hover-symbol::before { content: "@"; + /* color: var(--main-bullet-color);*/ } .title.hover-symbol::before { content: ">"; - color: var(--main-bullet-color); + /* color: var(--main-bullet-color);*/ } .hover-symbol { @@ -74,6 +75,7 @@ header { position: relative; font-weight: bold; text-decoration: none; + transition: color 0.15s ease; } .hover-symbol::before { @@ -85,20 +87,24 @@ header { top: 50%; transform: translateY(-50%); opacity: 0; - transition: opacity 0.2s ease; + 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); /* only the symbol changes color */ } .hover-symbol:hover { background-color: transparent; + /* color: var(--main-placeholder-color);*/ } .toclink { position: relative; text-decoration: none; color: inherit; + transition: color 0.15s ease; } .toclink::before { @@ -109,7 +115,16 @@ header { top: 50%; transform: translateY(-50%); opacity: 0; - transition: opacity 0.2s ease; + 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 { @@ -128,13 +143,6 @@ h4 .toclink::before { content: "###"; } -.toclink:hover::before { - opacity: 1; -} -.toclink:hover { - background-color: transparent; -} - /* h1, */ h2, h3, @@ -206,53 +214,26 @@ h6 { ul { list-style-type: disc; - /* or any other list style */ } li::marker { color: var(--main-bullet-color); - /* Change this to your desired color */ } -/*a {*/ -/* color: var(--main-link-color);*/ -/* text-decoration: none;*/ -/* position: relative;*/ -/*}*/ - -/*a::after {*/ -/* content: "";*/ -/* position: absolute;*/ -/* left: 0;*/ -/* bottom: -2px;*/ -/* width: 100%;*/ -/* height: 1px;*/ -/* background-color: currentColor;*/ -/* transform: scaleX(0);*/ -/* transform-origin: center;*/ -/* transition: transform 0.1s ease;*/ -/*}*/ - -/*a:hover::after {*/ -/* transform: scaleX(1);*/ -/*}*/ -/*a:has(> code)::after {*/ -/* display: none;*/ -/*}*/ - a { color: var(--main-link-color); text-decoration: underline; text-decoration-color: rgba(0, 0, 0, 0); - text-underline-offset: 2px; /* Optional: tweak spacing */ + text-underline-offset: 2px; } a { - transition: text-decoration-color 0.15s ease; + transition: color 0.15s ease, text-decoration-color 0.15s ease; } a:hover { - text-decoration-color: currentColor; + text-decoration-color: var(--main-placeholder-color); + color: var(--main-bullet-color); } max-width: 100%; @@ -489,23 +470,3 @@ caption { font-size: 0.8rem; color: var(--main-small-text-color); } - -a > code { - text-decoration: none; - color: var(--main-link-color); - position: relative; -} - -a:has(> code) { - text-decoration: none; - background: none; - /* position: static;*/ -} - -a:hover > code { - text-decoration: underline; -} - -a:hover:has(> code) { - background: none; -} From d35d3f61fbfa5aad4555a9d7dc83373ab8a71331 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Tue, 15 Jul 2025 18:42:32 -0400 Subject: [PATCH 63/76] updated documentation --- README.md | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index bd7eb9b..c3facfb 100644 --- a/README.md +++ b/README.md @@ -101,10 +101,6 @@ 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. -_Note: the previous build is _not_ cleaned before the new site is built. If -you've deleted some pages, you may need to remove the output directory before -rebuilding._ - ### Live Preview To make the writing process as frictionless as possible, zona ships with a live @@ -121,9 +117,10 @@ 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 or any templates, 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. +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 @@ -196,13 +193,17 @@ 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`. | +| 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 From a0fb62ac7abf7cf51dbf24a3b66d8c53e3d9d3f0 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Tue, 15 Jul 2025 18:43:28 -0400 Subject: [PATCH 64/76] release 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. --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e65c79..3790a07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# 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. diff --git a/pyproject.toml b/pyproject.toml index 6b517bc..7653d22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "zona" -version = "1.1.0" +version = "1.2.0" description = "Opinionated static site generator." license = "BSD-3-Clause " license-files = ["LICENSE"] diff --git a/uv.lock b/uv.lock index c1a3f41..da3457f 100644 --- a/uv.lock +++ b/uv.lock @@ -459,7 +459,7 @@ wheels = [ [[package]] name = "zona" -version = "1.1.0" +version = "1.2.0" source = { editable = "." } dependencies = [ { name = "dacite" }, From 8ad8bad438907c9c9f958d655904944fe0ffc53e Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Tue, 15 Jul 2025 23:24:06 -0400 Subject: [PATCH 65/76] updated styling of lists --- src/zona/data/content/static/style.css | 65 +++++++++++++++++++++++--- src/zona/data/templates/post_list.html | 28 ++++++----- 2 files changed, 71 insertions(+), 22 deletions(-) diff --git a/src/zona/data/content/static/style.css b/src/zona/data/content/static/style.css index 56644c3..caa0eef 100644 --- a/src/zona/data/content/static/style.css +++ b/src/zona/data/content/static/style.css @@ -61,13 +61,11 @@ header { } .site-logo.hover-symbol::before { - content: "@"; - /* color: var(--main-bullet-color);*/ + content: "~/"; } .title.hover-symbol::before { - content: ">"; - /* color: var(--main-bullet-color);*/ + content: "$"; } .hover-symbol { @@ -93,11 +91,28 @@ header { .hover-symbol:hover::before { opacity: 1; - color: var(--main-placeholder-color); /* only the symbol changes color */ + color: var(--main-placeholder-color); } .hover-symbol:hover { background-color: transparent; - /* color: var(--main-placeholder-color);*/ +} + +.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 { @@ -105,6 +120,15 @@ header { 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 { @@ -212,8 +236,35 @@ h6 { font-weight: bold; } +/*ul {*/ +/* list-style-type: disc;*/ +/*}*/ + ul { - list-style-type: disc; + 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 { diff --git a/src/zona/data/templates/post_list.html b/src/zona/data/templates/post_list.html index 85e0be8..6b0bdca 100644 --- a/src/zona/data/templates/post_list.html +++ b/src/zona/data/templates/post_list.html @@ -1,20 +1,18 @@ -{% extends "base.html" %} {% block content %} - -{% if metadata.show_title %} -{% include "title.html" %} -{% endif %} +{% extends "base.html" %} {% block content %} {% if metadata.show_title %} {% +include "title.html" %} {% endif %}
{{ content | safe }}
{% if post_list %} - +
+ +
{% endif %} {% endblock %} - From 4cef77e4e0723896b834fa8fdb2e25bdbb09953f Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Tue, 15 Jul 2025 23:39:00 -0400 Subject: [PATCH 66/76] ci: added test with cache --- .forgejo/workflows/test.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .forgejo/workflows/test.yml diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml new file mode 100644 index 0000000..3de0a8b --- /dev/null +++ b/.forgejo/workflows/test.yml @@ -0,0 +1,21 @@ +# 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: Check cache status + run: | + echo "Cache hit? ${{ steps.uv-cache.outputs.cache-hit }}" + - name: sync and build + run: | + uv sync + uv build + uv run zona --help From 3dc623f1c784077e237e78c4e4098b274fec6db8 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Wed, 16 Jul 2025 01:32:59 -0400 Subject: [PATCH 67/76] added --version flag to cli --- pyproject.toml | 2 +- src/zona/cli.py | 16 ++++++++++++++++ uv.lock | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7653d22..d852304 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "zona" -version = "1.2.0" +version = "1.2.1" description = "Opinionated static site generator." license = "BSD-3-Clause " license-files = ["LICENSE"] diff --git a/src/zona/cli.py b/src/zona/cli.py index 4b48d3a..da15a22 100644 --- a/src/zona/cli.py +++ b/src/zona/cli.py @@ -1,3 +1,4 @@ +from importlib.metadata import version as __version__ from pathlib import Path from typing import Annotated @@ -134,8 +135,23 @@ def serve( ) +def version_callback(value: bool): + if value: + print(f"Zona version: {__version__('zona')}") + raise typer.Exit() + + @app.callback() def main_entry( + version: Annotated[ # pyright: ignore[reportUnusedParameter] + bool | None, + typer.Option( + "--version", + callback=version_callback, + is_eager=True, + help="Print version info and exit.", + ), + ] = None, verbosity: Annotated[ str, typer.Option( diff --git a/uv.lock b/uv.lock index da3457f..7ec1e2e 100644 --- a/uv.lock +++ b/uv.lock @@ -459,7 +459,7 @@ wheels = [ [[package]] name = "zona" -version = "1.2.0" +version = "1.2.1" source = { editable = "." } dependencies = [ { name = "dacite" }, From dea02e3a4e1250faa43e12b45a6f2cf14e24bbd0 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Wed, 16 Jul 2025 01:33:53 -0400 Subject: [PATCH 68/76] ci: updated test to check version --- .forgejo/workflows/test.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml index 3de0a8b..2a05ee6 100644 --- a/.forgejo/workflows/test.yml +++ b/.forgejo/workflows/test.yml @@ -11,11 +11,8 @@ jobs: - name: setup cache id: uv-cache uses: https://git.ficd.sh/ficd/uv-cache@v1 - - name: Check cache status - run: | - echo "Cache hit? ${{ steps.uv-cache.outputs.cache-hit }}" - name: sync and build run: | uv sync uv build - uv run zona --help + uv run zona --version From 59948ec51715bbb7a6227b141c795b9dd960fbd2 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Wed, 16 Jul 2025 01:35:19 -0400 Subject: [PATCH 69/76] updated changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3790a07..104815a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 1.2.1 + +- Added `--version` flag to CLI. + # 1.2.0 - Improved the appearance and semantics of post navigation buttons. From ac8c88e2af49eb427b43810d17af020b35fd4f29 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Wed, 16 Jul 2025 01:37:50 -0400 Subject: [PATCH 70/76] ci: updated publish job to use cache --- .forgejo/workflows/publish.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.forgejo/workflows/publish.yml b/.forgejo/workflows/publish.yml index 53dd92f..fde3206 100644 --- a/.forgejo/workflows/publish.yml +++ b/.forgejo/workflows/publish.yml @@ -7,6 +7,9 @@ jobs: 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 From 4f8979ae9bbb8a91ae4f37ff8eb8da3e2ef02659 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Thu, 17 Jul 2025 21:58:21 -0400 Subject: [PATCH 71/76] fixed incorrect codeberg links in readme --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c3facfb..ccf5dd1 100644 --- a/README.md +++ b/README.md @@ -231,11 +231,11 @@ modified if they point to a real file that's not included in the ignore list. Zona uses [Pygments] to provide syntax highlighting for fenced code blocks. The following Pygments plugins are included: -- [pygments-kakoune](https://codeberg.com/ficd/pygments-kakoune) +- [pygments-kakoune](https://codeberg.org/ficd/pygments-kakoune) - A lexer providing for highlighting Kakoune code. Available under the `kak` and `kakrc` aliases. -- [pygments-ashen](https://codeberg.com/ficd/ashen/tree/main/item/pygments/README.md) - - An implementation of the [Ashen](https://codeberg.com/ficd/ashen) theme for +- [pygments-ashen](https://codeberg.org/ficd/ashen/tree/main/item/pygments/README.md) + - An implementation of the [Ashen](https://codeberg.org/ficd/ashen) theme for Pygments. If you want to use any external Pygments styles or lexers, they must be @@ -442,7 +442,7 @@ 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.com/ficd/ashen +[Ashen]: https://codeberg.org/ficd/ashen [Pygments]: https://pygments.org/ ## Known Problems From 828a82e5cb62f59b82b9680a64a050d5d7c10f76 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sat, 12 Jul 2025 01:06:31 -0400 Subject: [PATCH 72/76] feat: rss feed generation --- CHANGELOG.md | 6 +++ README.md | 63 ++++++++++++++++++++------ pyproject.toml | 3 +- src/zona/builder.py | 56 ++++++++++++++++++++++- src/zona/config.py | 51 ++++++++++++++++++++- src/zona/data/templates/page.html | 6 +-- src/zona/data/templates/post_list.html | 4 +- src/zona/layout.py | 7 ++- src/zona/metadata.py | 37 +++++++++++---- uv.lock | 52 +++++++++++++++++++++ 10 files changed, 251 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 104815a..269d2ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 1.3.0 + +- Added RSS feed generation. +- Added default post description to configuration. +- Added time-of-day support to post `date` frontmatter parsing. + # 1.2.1 - Added `--version` flag to CLI. diff --git a/README.md b/README.md index ccf5dd1..6fe124a 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ For an example of a website built with zona, please see - [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) @@ -49,6 +50,7 @@ For an example of a website built with zona, please see - 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. @@ -212,6 +214,17 @@ 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 @@ -309,18 +322,19 @@ 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. | -| `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. | +| 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/). @@ -374,12 +388,23 @@ useful settings are listed here. Please see the default configuration: ```yaml -base_url: / +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 @@ -392,6 +417,8 @@ build: include_drafts: false blog: dir: blog + defaults: + description: A blog post server: reload: enabled: true @@ -400,6 +427,15 @@ server: | 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. | @@ -410,6 +446,7 @@ server: | `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". | diff --git a/pyproject.toml b/pyproject.toml index d852304..b6d7de5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ authors = [ 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", @@ -57,7 +58,7 @@ reportUnusedCallResult = false reportCallInDefaultInitializer = false enableTypeIgnoreComments = true reportIgnoreCommentWithoutRule = false -allowedUntypedLibraries = ["frontmatter", "pygments", "pymdownx", "l2m4m"] +allowedUntypedLibraries = ["frontmatter", "pygments", "pymdownx", "l2m4m", "feedgen", "feedgen.feed"] [tool.ruff] line-length = 70 diff --git a/src/zona/builder.py b/src/zona/builder.py index 416edc8..37a3bd1 100644 --- a/src/zona/builder.py +++ b/src/zona/builder.py @@ -2,6 +2,8 @@ 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 @@ -55,7 +57,9 @@ class ZonaBuilder: layout.root / "content" / "static" ): logger.debug(f"Parsing {path.name}.") - item.metadata, item.content = parse_metadata(path) + 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 @@ -97,7 +101,45 @@ class ZonaBuilder: items.append(item) self.items = items - def _build(self): + def generate_feed(self) -> bytes: + post_list = self._get_post_list() + config = self.config.feed + if config.link.endswith("/"): + config.link = config.link[:-2] + fg = FeedGenerator() + fg.id(config.link) + fg.title(config.title) + author = { + "name": config.author.name, + "email": config.author.email, + } + fg.author(author) + fg.link( + href=f"{config.link}/{config.path}", + rel="self", + type="application/rss+xml", + ) + fg.language(config.language) + fg.description(config.description) + + for post in post_list: + assert post.metadata + fe = fg.add_entry() # pyright: ignore[reportUnknownVariableType] + fe.id(f"{config.link}{util.normalize_url(post.url)}") # pyright: ignore[reportUnknownMemberType] + fe.link( # pyright: ignore[reportUnknownMemberType] + href=f"{config.link}{util.normalize_url(post.url)}" + ) + fe.title(post.metadata.title) # pyright: ignore[reportUnknownMemberType] + fe.author(author) # pyright: ignore[reportUnknownMemberType] + desc = post.metadata.description + fe.description(desc) # pyright: ignore[reportUnknownMemberType] + date = post.metadata.date + fe.pubDate(date) # pyright: ignore[reportUnknownMemberType] + out: bytes = fg.rss_str(pretty=True) # pyright: ignore[reportUnknownVariableType] + assert isinstance(out, bytes) + return out + + def _get_post_list(self) -> list[Item]: assert self.items # sort according to date # descending order @@ -108,7 +150,12 @@ 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): @@ -180,4 +227,9 @@ class ZonaBuilder: self._discover() logger.debug("Building...") self._build() + if self.config.feed.enabled: + rss = self.generate_feed() + path = self.layout.content / self.config.feed.path + util.ensure_parents(path) + path.write_bytes(rss) self.fresh = False diff --git a/src/zona/config.py b/src/zona/config.py index 2a50fd4..84844c7 100644 --- a/src/zona/config.py +++ b/src/zona/config.py @@ -1,7 +1,13 @@ +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 @@ -25,9 +31,17 @@ def find_config(start: Path | None = None) -> Path | None: SitemapConfig = dict[str, str] +@dataclass +class PostDefaultsConfig: + description: str = "A blog post" + + @dataclass class BlogConfig: dir: str = "blog" + defaults: PostDefaultsConfig = field( + default_factory=PostDefaultsConfig + ) @dataclass @@ -69,12 +83,40 @@ class ServerConfig: reload: ReloadConfig = field(default_factory=ReloadConfig) +@dataclass +class AuthorConfig: + name: str = "John Doe" + email: str = "john@doe.net" + + +@dataclass +class FeedConfig: + enabled: bool = True + timezone: tzinfo = field(default_factory=lambda: ZoneInfo("UTC")) + path: str = "rss.xml" + link: str = "https://example.com" + title: str = "Zona Website" + description: str = "My zona website." + language: str = "en" + author: AuthorConfig = field(default_factory=AuthorConfig) + + IGNORELIST = [".marksman.toml"] +def parse_timezone(s: Any) -> tzinfo: + if isinstance(s, str): + return ZoneInfo(s) + else: + raise TypeError( + f"Expected {str}, got {type(s)} for config key timezone" + ) + + @dataclass class ZonaConfig: base_url: str = "/" + feed: FeedConfig = field(default_factory=FeedConfig) # dictionary where key is name, value is url sitemap: SitemapConfig = field( default_factory=lambda: {"Home": "/"} @@ -87,7 +129,12 @@ class ZonaConfig: server: ServerConfig = field(default_factory=ServerConfig) @classmethod - def from_file(cls, path: Path) -> "ZonaConfig": + def from_file(cls, path: Path) -> ZonaConfig: with open(path, "r") as f: raw = yaml.safe_load(f) - return from_dict(data_class=cls, data=raw) + config: ZonaConfig = from_dict( + data_class=cls, + data=raw, + config=DaciteConfig(type_hooks={tzinfo: parse_timezone}), + ) + return config diff --git a/src/zona/data/templates/page.html b/src/zona/data/templates/page.html index 0c22f8b..0ff499b 100644 --- a/src/zona/data/templates/page.html +++ b/src/zona/data/templates/page.html @@ -1,8 +1,8 @@ {% extends "base.html" %} {% block content %} {% if metadata.show_title %} {% -include "title.html" %} {% if metadata.date %} +include "title.html" %} {% if metadata.date.date() %}
- +
{% endif %} {% endif %} {% if is_post %} {% include "post_nav.html" %} {% endif %} diff --git a/src/zona/data/templates/post_list.html b/src/zona/data/templates/post_list.html index 6b0bdca..30ea74f 100644 --- a/src/zona/data/templates/post_list.html +++ b/src/zona/data/templates/post_list.html @@ -8,8 +8,8 @@ include "title.html" %} {% endif %}
    {% for item in post_list %}
  • - : {{ item.metadata.date.date() | safe}}: {{ item.metadata.title }}
  • {% endfor %} diff --git a/src/zona/layout.py b/src/zona/layout.py index 3d8de3d..e912dd0 100644 --- a/src/zona/layout.py +++ b/src/zona/layout.py @@ -1,5 +1,6 @@ from dataclasses import asdict, dataclass from pathlib import Path +from zoneinfo import ZoneInfo import typer import yaml @@ -145,9 +146,13 @@ def initialize_site(root: Path | None = None): logger.debug("Loading default configuation.") config = ZonaConfig() logger.debug(f"Writing default configuration to {config_path}.") + config_dict = asdict(config) + if "feed" in config_dict and "timezone" in config_dict["feed"]: + tz: ZoneInfo = config_dict["feed"]["timezone"] + config_dict["feed"]["timezone"] = tz.key with open(config_path, "w") as f: yaml.dump( - asdict(config), + config_dict, f, sort_keys=False, default_flow_style=False, diff --git a/src/zona/metadata.py b/src/zona/metadata.py index 98b14cf..4551117 100644 --- a/src/zona/metadata.py +++ b/src/zona/metadata.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from datetime import date +from datetime import date, datetime, time, tzinfo from pathlib import Path import frontmatter @@ -10,13 +10,14 @@ from dateutil import parser as date_parser from yaml import YAMLError import zona.util +from zona.config import ZonaConfig @dataclass class Metadata: title: str - date: date - description: str | None + date: datetime + description: str show_title: bool = True show_date: bool = True show_nav: bool = True @@ -30,14 +31,29 @@ class Metadata: math: bool = True -def parse_date(raw_date: str | date | object) -> date: - if isinstance(raw_date, date): - return raw_date +def ensure_timezone(dt: datetime, tz: tzinfo) -> datetime: + if dt.tzinfo is None or dt.utcoffset() is None: + dt = dt.replace(tzinfo=tz) + return dt + + +# TODO: migrate to using datetime, where user can optionall specify +# a time as well. if only date is given, default to time.min +def parse_date( + raw_date: str | datetime | date | object, tz: tzinfo +) -> datetime: + if isinstance(raw_date, datetime): + return ensure_timezone(raw_date, tz) + elif isinstance(raw_date, date): + return datetime.combine(raw_date, time.min, tzinfo=tz) assert isinstance(raw_date, str) - return date_parser.parse(raw_date).date() + dt = date_parser.parse(raw_date) + return ensure_timezone(dt, tz) -def parse_metadata(path: Path) -> tuple[Metadata, str]: +def parse_metadata( + path: Path, config: ZonaConfig +) -> tuple[Metadata, str]: """ Parses a file and returns parsed Metadata and its content. Defaults are applied for missing fields. If there is no metadata, a Metadata @@ -53,10 +69,11 @@ def parse_metadata(path: Path) -> tuple[Metadata, str]: raw_meta = post.metadata or {} defaults = { "title": zona.util.filename_to_title(path), - "date": date.fromtimestamp(path.stat().st_ctime), + "date": datetime.fromtimestamp(path.stat().st_ctime), + "description": config.blog.defaults.description, } meta = {**defaults, **raw_meta} - meta["date"] = parse_date(meta.get("date")) + meta["date"] = parse_date(meta.get("date"), config.feed.timezone) try: metadata = from_dict( data_class=Metadata, diff --git a/uv.lock b/uv.lock index 7ec1e2e..c81743b 100644 --- a/uv.lock +++ b/uv.lock @@ -44,6 +44,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/35/386550fd60316d1e37eccdda609b074113298f23cef5bddb2049823fe666/dacite-1.9.2-py3-none-any.whl", hash = "sha256:053f7c3f5128ca2e9aceb66892b1a3c8936d02c686e707bee96e19deef4bc4a0", size = 16600, upload-time = "2025-02-05T09:27:24.345Z" }, ] +[[package]] +name = "feedgen" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/59/be0a6f852b5dfbf19e6c8e962c8f41407697f9f52a7902250ed98683ae89/feedgen-1.0.0.tar.gz", hash = "sha256:d9bd51c3b5e956a2a52998c3708c4d2c729f2fcc311188e1e5d3b9726393546a", size = 258496, upload-time = "2023-12-25T18:04:08.421Z" } + [[package]] name = "iniconfig" version = "2.1.0" @@ -87,6 +97,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/fd/aba08bb9e527168efad57985d7db9a853eb2384b1efa5ca5f3a3794c9cef/latex2mathml-3.78.0-py3-none-any.whl", hash = "sha256:1aeca3dc027b3006ad7b301b7f4a15ffbb4c1451e3dc8c3389e97b37b497e1d6", size = 73673, upload-time = "2025-05-03T16:51:51.991Z" }, ] +[[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" @@ -463,6 +513,7 @@ version = "1.2.1" source = { editable = "." } dependencies = [ { name = "dacite" }, + { name = "feedgen" }, { name = "jinja2" }, { name = "l2m4m" }, { name = "markdown" }, @@ -489,6 +540,7 @@ 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" }, From d8d1e991c2517ef3180856279df80f8779433e3f Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Fri, 18 Jul 2025 00:23:51 -0400 Subject: [PATCH 73/76] feat: init now only includes footer template --- src/zona/layout.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/zona/layout.py b/src/zona/layout.py index e912dd0..f4c7311 100644 --- a/src/zona/layout.py +++ b/src/zona/layout.py @@ -125,7 +125,8 @@ def initialize_site(root: Path | None = None): layout = Layout.from_input(root=root, validate=False) # load template resources logger.debug("Loading internal templates.") - templates = util.get_resources("templates") + # only write the footer + templates = [util.get_resource("templates/footer.md")] logger.debug("Loading internal static content.") static = util.get_resources("content") for dir, resources in [ From 312818d8a6e098494e2f5254cfbd1240b2b7cd84 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Fri, 18 Jul 2025 00:38:32 -0400 Subject: [PATCH 74/76] doc: updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 269d2ab..9f6eed9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - 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 From 182b30a4efcd4e9c9898a84b511bedf904afad00 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Fri, 18 Jul 2025 00:41:04 -0400 Subject: [PATCH 75/76] fix: rss feed is no longer written to content directory --- src/zona/builder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zona/builder.py b/src/zona/builder.py index 37a3bd1..dd9509f 100644 --- a/src/zona/builder.py +++ b/src/zona/builder.py @@ -229,7 +229,7 @@ class ZonaBuilder: self._build() if self.config.feed.enabled: rss = self.generate_feed() - path = self.layout.content / self.config.feed.path + path = self.layout.output / self.config.feed.path util.ensure_parents(path) path.write_bytes(rss) self.fresh = False From 66a3eb7f8c89242a393e188549b415b0a5764586 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Fri, 18 Jul 2025 00:42:45 -0400 Subject: [PATCH 76/76] release: 1.2.2 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b6d7de5..185c12d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "zona" -version = "1.2.1" +version = "1.2.2" description = "Opinionated static site generator." license = "BSD-3-Clause " license-files = ["LICENSE"]