diff --git a/.forgejo/workflows/publish.yml b/.forgejo/workflows/publish.yml deleted file mode 100644 index fde3206..0000000 --- a/.forgejo/workflows/publish.yml +++ /dev/null @@ -1,20 +0,0 @@ -on: - push: - tags: - - 'v*' -jobs: - publish: - runs-on: based-alpine - steps: - - uses: actions/checkout@v4 - - name: setup cache - id: uv-cache - uses: https://git.ficd.sh/ficd/uv-cache@v1 - - name: build - run: | - uv sync - uv build - - name: publish - run: | - uv publish --token ${{ secrets.PYPI_TOKEN }} - diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml deleted file mode 100644 index 2a05ee6..0000000 --- a/.forgejo/workflows/test.yml +++ /dev/null @@ -1,18 +0,0 @@ -# this workflow checks if the project can be built successfully. -# it also serves to test whether based-alpine and uv-cache are working properly. -# Unit tests will be added here eventually -on: [push] -jobs: - test-build: - runs-on: based-alpine - steps: - - name: checkout source - uses: actions/checkout@v4 - - name: setup cache - id: uv-cache - uses: https://git.ficd.sh/ficd/uv-cache@v1 - - name: sync and build - run: | - uv sync - uv build - uv run zona --version diff --git a/.kakrc b/.kakrc deleted file mode 100644 index 688bcbe..0000000 --- a/.kakrc +++ /dev/null @@ -1,25 +0,0 @@ -# commands to edit important files in the root -declare-option str project_root %sh{ git rev-parse --show-toplevel } - -define-command -params 1 root-edit %{ - edit %exp{%opt{project_root}/%arg{1}} -} - -define-command just %{ - root-edit justfile -} -define-command pyproject %{ - root-edit pyproject.toml -} -define-command readme %{ - root-edit README.md -} - -define-command kakrc %{ - root-edit .kakrc -} - -# change working directory to the package -hook global -once BufCreate .* %{ - change-directory %exp{%opt{project_root}/src/zona} -} diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 9f6eed9..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,33 +0,0 @@ -# 1.3.0 - -- Added RSS feed generation. -- Added default post description to configuration. -- Added time-of-day support to post `date` frontmatter parsing. -- `zona init` now only writes `footer.md` to the templates directory. - -# 1.2.1 - -- Added `--version` flag to CLI. - -# 1.2.0 - -- Improved the appearance and semantics of post navigation buttons. - - Navigation now follows "newer/older" logic. -- Added hover symbols to page titles. -- Improved the styling of hover symbols and links. - -# 1.1.0 - -- Major improvements to default stylesheet. -- Frontmatter option to ignore file. -- Improvements to title and date rendering in templates. -- Added smooth scrolling to default stylesheet. -- Fixed a crash when user templates directory was missing when starting the - server. -- Added "next/previous" navigation buttons to posts. -- User template directory is now merged with defaults instead of it being one or - the other. - -# 1.0.0 - -Initial release! diff --git a/LICENSE b/LICENSE index 1cbda58..ac101d3 100644 --- a/LICENSE +++ b/LICENSE @@ -1,24 +1,13 @@ -Copyright (c) 2025 Daniel Fichtinger +Copyright (c) 2025 Daniel Fichtinger -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions -are met: -1. Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. -2. Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. -3. Neither the name of the author nor the names of its contributors may - be used to endorse or promote products derived from this software +Permission to use, copy, modify, and distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. -THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS -OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) -HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT -LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY -OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF -SUCH DAMAGE. +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/README.md b/README.md index 6fe124a..5b35e8c 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,27 @@

zona

-[zona](https://git.ficd.sh/ficd/zona) is an _opinionated_ static site generator -written in Python. From a structured directory of Markdown content, zona builds -a simple static website. It's designed to get out of your way and let you focus -on writing. +[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. -**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. 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). +[ficd.sh](https://ficd.sh). @@ -23,15 +31,12 @@ 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) - [Markdown Footer](#markdown-footer) - - [RSS Feed Generation](#rss-feed-generation) - [Internal Link Resolution](#internal-link-resolution) - [Syntax Highlighting](#syntax-highlighting) - - [Markdown Extensions](#markdown-extensions) - [Image Labels](#image-labels) - [Frontmatter](#frontmatter) - [Post List](#post-list) @@ -39,7 +44,6 @@ For an example of a website built with zona, please see - [Sitemap](#sitemap) - [Ignore List](#ignore-list) - [Drafts](#drafts) -- [Known Problems](#known-problems) @@ -50,35 +54,29 @@ 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. - 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 -Zona can be installed as a Python package. Instructions for -[`uv`](https://docs.astral.sh/uv/) are provided. +zona is not yet packaged on PyPI. You may use `uv` to install it from this +repository: ```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 +uv tool install 'git+https://git.sr.ht/~ficd/zona' ``` ## Usage @@ -88,75 +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._ ### 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._ -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. +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. -**Note**: if the live preview isn't working as expected, try restarting the -server. If you change the configuration, the server must also be restarted. The -live preview uses the same function as `zona build` internally; this means that -the output is also written to disk --- a temporary directory by default, unless -overridden with `-o/--output`. +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._ -#### Live Reload - -Optionally, live reloading of the browser is also provided. With this feature -(enabled by default), your browser will automatically refresh open pages -whenever the site is rebuilt. The live reloading requires JavaScript support -from the browser — this is why the feature is optional. - -To start a preview server, use `zona serve`. You can specify the root directory -as its first argument. Use the `--host` to specify a host name (`localhost` by -default) and `--port/-p` to specify a port (default: `8000`). - -The `--live-reload/--no-live-reload` option overrides the value set in the -[config](#configuration) (`true` by default). _Automatic site rebuilds are not -affected_. - -If you are scrolled to the bottom of the page in the browser, and you extend the -height of the page by adding new content, you will automatically be scrolled to -the _new_ bottom after reloading. You may tune the tolerance threshold in the -[configuration](#configuration). +**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 @@ -169,91 +160,80 @@ templates/ public/ ``` -The **root** of the zona **project** _must_ contain the configuration file, -`config.yml`, and a directory called `content`. A directory called `templates` -is optional, and merged with the defaults if it exists. `public` is the built -site output — it's recommended to add this path to your `.gitignore`. +The **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. 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: +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`. | -| `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. | +| 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.** - -### 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`. +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://codeberg.org/ficd/pygments-kakoune) - - A lexer providing for highlighting Kakoune code. Available under the `kak` - and `kakrc` aliases. -- [pygments-ashen](https://codeberg.org/ficd/ashen/tree/main/item/pygments/README.md) - - An implementation of the [Ashen](https://codeberg.org/ficd/ashen) theme for - Pygments. +- [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. 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 @@ -268,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="")` @@ -296,9 +276,10 @@ 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) @@ -313,28 +294,26 @@ 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 | -| ------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------ | -| `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. | +| 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. | +| `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/). @@ -355,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: @@ -368,37 +348,26 @@ 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 -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: @@ -410,84 +379,48 @@ markdown: enabled: true theme: ashen wrap: false - links: - external_new_tab: true -build: - clean_output_dir: true - include_drafts: false blog: dir: blog - defaults: - description: A blog post -server: - reload: - enabled: true - scroll_tolerance: 100 ``` -| Name | Description | -| -------------------------------------- | ----------------------------------------------------------------------------------------------- | -| `feed.enabled` | Whether RSS feed should be generated. **Off by default**. | -| `feed.timezime` | Timezone to use for post `pubDate` values. Must be an IANA compliant string. | -| `feed.path` | Location of the feed, relative to content root. | -| `feed.link` | The base URL of the website. | -| `feed.title` | Website title. | -| `feed.description` | Website description. | -| `feed.language` | String specifying website's language code. | -| `author.name` | Your full name. | -| `author.email` | Your email address. | -| `sitemap` | Sitemap dictionary. See [Sitemap](#sitemap). | -| `ignore` | List of paths to ignore. See [Ignore List](#ignore-list). | -| `markdown.tab_length` | How many spaces should be considered an indentation level. | -| `markdown.syntax_highlighting.enabled` | Whether code should be highlighted. | -| `markdown.syntax_highlighting.theme` | [Pygments] style for highlighting. | -| `markdown.syntax_highlighting.wrap` | Whether the resulting code block should be word wrapped. | -| `markdown.links.external_new_tab` | Whether external links should be opened in a new tab. | -| `build.clean_output_dir` | Whether previous build artifacts should be cleared when building. Recommended to leave this on. | -| `build.include_drafts` | Whether drafts should be included by default. | -| `blog.dir` | Name of a directory relative to `content/` whose children are automatically considered posts. | -| `blog.defaults.description` | Default description for blog posts with no `description` in their frontmatter. | -| `server.reload.enabled` | Whether the preview server should use [live reload](#live-preview). | -| `server.reload.scroll_tolerance` | The distance, in pixels, from the bottom to still count as "scrolled to bottom". | +| 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: +You can define a sitemap in the configuration file. This is a list of +links that will be rendered at the top of every page. The `sitemap` is a +dictionary of `string` to `string` pairs, where each key is the displayed +text of the link, and the value if the `href`. Consider this example: ```yaml sitemap: Home: / About: /about Blog: /blog - Git: https://git.ficd.sh/ficd + 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. +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://codeberg.org/ficd/ashen +[Ashen]: https://sr.ht/~ficd/ashen [Pygments]: https://pygments.org/ - -## Known Problems - -1. If the user triggers rebuilds in quick succession, the browser is sent the - reload command after the first build, even though a second build may be - underway. This results in a `404` page being served, and the user needs to - manually refresh the browser page. - - **Mitigation:** Don't allow a rebuild until the browser has re-connected to - the WebSocket after the first reload. diff --git a/justfile b/justfile index c0f2e14..cfb1ed2 100644 --- a/justfile +++ b/justfile @@ -2,28 +2,6 @@ default: @just --list -clean: - #!/bin/sh - if [ ! -d "dist" ] && [ ! -d "__pycache__" ]; then - echo "Nothing to clean." - exit 0 - fi - if [ -d "dist" ]; then - echo "Removing dist/" - rm -r dist/ - fi - if [ -d "__pycache__" ]; then - echo "Removing __pycache__/" - rm -r "__pycache__" - fi - -publish: - #!/bin/sh - just clean - uv build - export UV_PUBLISH_TOKEN="$(pass show pypi)" - uv publish - format: uv run ruff format diff --git a/pyproject.toml b/pyproject.toml index 185c12d..aea7a42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,17 +1,14 @@ [project] name = "zona" -version = "1.2.2" -description = "Opinionated static site generator." -license = "BSD-3-Clause " -license-files = ["LICENSE"] +version = "0.1.0" +description = "Static site generator" readme = "README.md" authors = [ - { name = "Daniel Fichtinger", email = "daniel@ficd.sh" }, + { name = "Daniel Fichtinger", email = "daniel@ficd.ca" }, ] requires-python = ">=3.12" dependencies = [ "dacite>=1.9.2", - "feedgen>=1.0.0", "jinja2>=3.1.6", "l2m4m>=1.0.4", "markdown>=3.8.2", @@ -27,9 +24,6 @@ dependencies = [ "websockets>=15.0.1", ] -[project.urls] -Repository = "https://git.ficd.sh/ficd/zona" - [project.scripts] zona = "zona.cli:main" @@ -58,12 +52,12 @@ reportUnusedCallResult = false reportCallInDefaultInitializer = false enableTypeIgnoreComments = true reportIgnoreCommentWithoutRule = false -allowedUntypedLibraries = ["frontmatter", "pygments", "pymdownx", "l2m4m", "feedgen", "feedgen.feed"] +allowedUntypedLibraries = ["frontmatter", "pygments", "pymdownx", "l2m4m"] [tool.ruff] line-length = 70 indent-width = 4 -target-version = "py312" +target-version = "py311" [tool.ruff.lint] fixable = ["ALL"] diff --git a/src/zona/builder.py b/src/zona/builder.py index dd9509f..31140c8 100644 --- a/src/zona/builder.py +++ b/src/zona/builder.py @@ -1,9 +1,6 @@ -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 @@ -32,7 +29,6 @@ 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 @@ -51,16 +47,14 @@ class ZonaBuilder: destination=destination, url=str(destination.relative_to(layout.output)), ) - if path.name.endswith( - ".md" - ) and not path.is_relative_to( + if path.name.endswith(".md") and not path.is_relative_to( layout.root / "content" / "static" ): - logger.debug(f"Parsing {path.name}.") - item.metadata, item.content = parse_metadata( - path, config=self.config + logger.debug( + f"Parsing {path.name} as Markdown document." ) - if item.metadata.ignore or ( + item.metadata, item.content = parse_metadata(path) + if ( item.metadata.draft and not self.config.build.include_drafts ): @@ -76,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 @@ -94,55 +86,13 @@ class ZonaBuilder: layout.output ) item.url = ( - "" - if rel_url == Path(".") - else rel_url.as_posix() + "" if rel_url == Path(".") else rel_url.as_posix() ) items.append(item) self.items = items - def generate_feed(self) -> bytes: - post_list = self._get_post_list() - config = self.config.feed - if config.link.endswith("/"): - config.link = config.link[:-2] - fg = FeedGenerator() - fg.id(config.link) - fg.title(config.title) - author = { - "name": config.author.name, - "email": config.author.email, - } - fg.author(author) - fg.link( - href=f"{config.link}/{config.path}", - rel="self", - type="application/rss+xml", - ) - fg.language(config.language) - fg.description(config.description) - - for post in post_list: - assert post.metadata - fe = fg.add_entry() # pyright: ignore[reportUnknownVariableType] - fe.id(f"{config.link}{util.normalize_url(post.url)}") # pyright: ignore[reportUnknownMemberType] - fe.link( # pyright: ignore[reportUnknownMemberType] - href=f"{config.link}{util.normalize_url(post.url)}" - ) - fe.title(post.metadata.title) # pyright: ignore[reportUnknownMemberType] - fe.author(author) # pyright: ignore[reportUnknownMemberType] - desc = post.metadata.description - fe.description(desc) # pyright: ignore[reportUnknownMemberType] - date = post.metadata.date - fe.pubDate(date) # pyright: ignore[reportUnknownMemberType] - out: bytes = fg.rss_str(pretty=True) # pyright: ignore[reportUnknownVariableType] - assert isinstance(out, bytes) - return out - - def _get_post_list(self) -> list[Item]: + def _build(self): assert self.items - # sort according to date - # descending order post_list: list[Item] = sorted( [item for item in self.items if item.post], key=lambda item: item.metadata.date @@ -150,22 +100,6 @@ class ZonaBuilder: else date.min, reverse=True, ) - return post_list - - def _build(self): - post_list = self._get_post_list() - # number of posts - # generate RSS here - posts = len(post_list) - # link post chronology - for i, item in enumerate(post_list): - # prev: older post - older = post_list[i + 1] if i + 1 < posts else None - # next: newer post - newer = post_list[i - 1] if i > 0 else None - item.older = older - item.newer = newer - templater = Templater( config=self.config, template_dir=self.layout.templates, @@ -178,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(): @@ -208,28 +140,7 @@ class ZonaBuilder: util.copy_static_file(item.source, dst) def build(self): - # clean output if applicable - if ( - self.config.build.clean_output_dir - and self.layout.output.is_dir() - ): - logger.debug("Removing stale output...") - # only remove output dir's children - # to avoid breaking live preview - for child in self.layout.output.iterdir(): - if child.is_file() or child.is_symlink(): - child.unlink() - elif child.is_dir(): - shutil.rmtree(child) - if not self.fresh: - self.layout = self.layout.refresh() logger.debug("Discovering...") self._discover() logger.debug("Building...") self._build() - if self.config.feed.enabled: - rss = self.generate_feed() - path = self.layout.output / self.config.feed.path - util.ensure_parents(path) - path.write_bytes(rss) - self.fresh = False diff --git a/src/zona/cli.py b/src/zona/cli.py index da15a22..c1017ab 100644 --- a/src/zona/cli.py +++ b/src/zona/cli.py @@ -1,4 +1,3 @@ -from importlib.metadata import version as __version__ from pathlib import Path from typing import Annotated @@ -91,24 +90,21 @@ def serve( output: Annotated[ Path | None, typer.Option( - "--output", - "-o", - help="Location to write built website. Temporary directory by default.", + "--output", "-o", help="Location to write built website" ), ] = None, - final: Annotated[ + draft: Annotated[ bool, - typer.Option("--final", "-f", help="Don't include drafts."), + typer.Option("--draft", "-d", help="Include drafts."), ] = False, - live_reload: Annotated[ - bool | None, + no_live_reload: Annotated[ + bool, typer.Option( - "--live-reload/--no-live-reload", - "-l/-L", - help="Automatically reload web preview. Overrides config.", - show_default=False, + "--no-live-reload", + "-n", + help="Don't automatically reload web preview.", ), - ] = None, + ] = False, ): """ Build the website and start a live preview server. @@ -117,41 +113,20 @@ def serve( Optionally specify the ROOT and OUTPUT directories. """ - if final: - print("Preview without drafts.") - else: - print("Preview with drafts.") - if live_reload is None: - reload = None - else: - reload = live_reload + if draft: + print("Option override: including drafts.") server.serve( root=root, output=output, - draft=not final, + draft=draft, host=host, port=port, - user_reload=reload, + live_reload=not no_live_reload, ) -def version_callback(value: bool): - if value: - print(f"Zona version: {__version__('zona')}") - raise typer.Exit() - - @app.callback() def main_entry( - version: Annotated[ # pyright: ignore[reportUnusedParameter] - bool | None, - typer.Option( - "--version", - callback=version_callback, - is_eager=True, - help="Print version info and exit.", - ), - ] = None, verbosity: Annotated[ str, typer.Option( diff --git a/src/zona/config.py b/src/zona/config.py index 84844c7..de3c985 100644 --- a/src/zona/config.py +++ b/src/zona/config.py @@ -1,13 +1,7 @@ -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 @@ -31,17 +25,9 @@ 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 @@ -51,11 +37,6 @@ class HighlightingConfig: wrap: bool = False -@dataclass -class LinksConfig: - external_new_tab: bool = True - - @dataclass class MarkdownConfig: image_labels: bool = True @@ -63,78 +44,30 @@ class MarkdownConfig: syntax_highlighting: HighlightingConfig = field( default_factory=HighlightingConfig ) - links: LinksConfig = field(default_factory=LinksConfig) @dataclass class BuildConfig: - clean_output_dir: bool = True + # clean_output_dir: bool = True include_drafts: bool = False -@dataclass -class ReloadConfig: - enabled: bool = True - scroll_tolerance: int = 100 - - -@dataclass -class ServerConfig: - reload: ReloadConfig = field(default_factory=ReloadConfig) - - -@dataclass -class AuthorConfig: - name: str = "John Doe" - email: str = "john@doe.net" - - -@dataclass -class FeedConfig: - enabled: bool = True - timezone: tzinfo = field(default_factory=lambda: ZoneInfo("UTC")) - path: str = "rss.xml" - link: str = "https://example.com" - title: str = "Zona Website" - description: str = "My zona website." - language: str = "en" - author: AuthorConfig = field(default_factory=AuthorConfig) - - IGNORELIST = [".marksman.toml"] -def parse_timezone(s: Any) -> tzinfo: - if isinstance(s, str): - return ZoneInfo(s) - else: - raise TypeError( - f"Expected {str}, got {type(s)} for config key timezone" - ) - - @dataclass class ZonaConfig: base_url: str = "/" - feed: FeedConfig = field(default_factory=FeedConfig) # dictionary where key is name, value is url - sitemap: SitemapConfig = field( - default_factory=lambda: {"Home": "/"} - ) + sitemap: SitemapConfig = field(default_factory=lambda: {"Home": "/"}) # list of globs relative to content that should be ignored ignore: list[str] = field(default_factory=lambda: IGNORELIST) markdown: MarkdownConfig = field(default_factory=MarkdownConfig) build: BuildConfig = field(default_factory=BuildConfig) blog: BlogConfig = field(default_factory=BlogConfig) - server: ServerConfig = field(default_factory=ServerConfig) @classmethod - def from_file(cls, path: Path) -> ZonaConfig: + def from_file(cls, path: Path) -> "ZonaConfig": with open(path, "r") as f: raw = yaml.safe_load(f) - config: ZonaConfig = from_dict( - data_class=cls, - data=raw, - config=DaciteConfig(type_hooks={tzinfo: parse_timezone}), - ) - return config + return from_dict(data_class=cls, data=raw) diff --git a/src/zona/data/content/static/style.css b/src/zona/data/content/static/style.css index caa0eef..0ebd3fb 100644 --- a/src/zona/data/content/static/style.css +++ b/src/zona/data/content/static/style.css @@ -1,170 +1,20 @@ :root { - --main-placeholder-color: #b14242; - --main-text-color: #b4b4b4; - --main-text-opaque-color: rgba(180, 180, 180, 0.8); - --main-bg-color: #121212; - --main-link-color: #df6464; - --main-heading-color: #df6464; - --main-bullet-color: #d87c4a; - --orange-rgb: rgba(216, 124, 74, 0.6); - --main-transparent: rgba(255, 255, 255, 0.15); - --main-small-text-color: rgba(255, 255, 255, 0.45); -} - -html { - scroll-behavior: smooth; + --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 { - margin: 0; - line-height: 1.6; - font-size: 18px; - font-family: sans-serif; - background: var(--main-bg-color); - color: var(--main-text-color); - padding-left: calc(100vw - 100%); -} - -header { - padding-top: -1rem; - margin-top: -1rem; - font-family: monospace; - text-transform: lowercase; -} - -.post-nav { - font-family: monospace; - font-size: 0.95em; - white-space: nowrap; -} - -.post-nav .bar { - position: relative; - bottom: 0.05em; - display: inline-block; - width: 1px; - height: 0.8em; - background-color: currentColor; - vertical-align: middle; - margin: 0 0.3em; -} - -.post-nav .placeholder { - color: var(--main-placeholder-color); -} - -.post-nav .symbol { - color: var(--main-bullet-color); - margin: 0; - padding: 0; - display: inline; -} - -.site-logo.hover-symbol::before { - content: "~/"; -} - -.title.hover-symbol::before { - content: "$"; -} - -.hover-symbol { - color: inherit; - position: relative; - font-weight: bold; - text-decoration: none; - transition: color 0.15s ease; -} - -.hover-symbol::before { - font-family: monospace; - content: "#"; - position: absolute; - right: 100%; - margin-right: 0.25em; - top: 50%; - transform: translateY(-50%); - opacity: 0; - transition: opacity 0.15s ease, color 0.15s ease; - color: var(--main-text-color); -} - -.hover-symbol:hover::before { - opacity: 1; - color: var(--main-placeholder-color); -} -.hover-symbol:hover { - background-color: transparent; -} - -.toc ul { - font-family: monospace; - text-transform: lowercase; - margin: auto; - width: 50%; -} - -.toc ul ul { - padding-left: 1em; - margin-left: 1em; - /* list-style-type: "–– ";*/ -} -.toc ul ul ul { - padding-left: 1em; - margin-left: 1em; - /* list-style-type: "-- ";*/ -} - -.toclink { - position: relative; - text-decoration: none; - color: inherit; - transition: color 0.15s ease; - text-transform: lowercase; - font-family: monospace; -} -.post-list a { - position: relative; - text-decoration: none; - transition: color 0.15s ease; - text-transform: lowercase; - font-family: monospace; -} - -.toclink::before { - content: "#"; - position: absolute; - right: 100%; - margin-right: 0.25em; - top: 50%; - transform: translateY(-50%); - opacity: 0; - transition: opacity 0.15s ease, color 0.15s ease; - color: var(--main-link-color); -} - -.toclink:hover::before { - opacity: 1; - color: var(--main-placeholder-color); -} -.toclink:hover { - background-color: transparent; -} - -h1 .toclink::before { - content: "#"; -} - -h2 .toclink::before { - content: "#"; -} - -h3 .toclink::before { - content: "##"; -} - -h4 .toclink::before { - content: "###"; + 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%); } /* h1, */ @@ -173,351 +23,266 @@ 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; -} - -.title { - text-transform: lowercase; - font-family: monospace; -} - -.title a { - color: inherit; - text-decoration: none; + 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;*/ -/*}*/ - ul { - list-style-type: "– "; -} -ul ul { - padding-left: 1em; - margin-left: 1em; - list-style-type: "+ "; -} -ul ul ul { - list-style-type: "~ "; -} -ul ul ul ul { - list-style-type: "• "; -} -ul ul ul ul ul { - list-style-type: "– "; -} -ul ul ul ul ul ul { - list-style-type: "+ "; -} -ul ul ul ul ul ul ul { - list-style-type: "~ "; -} -ul ul ul ul ul ul ul ul { - list-style-type: "• "; + list-style-type: disc; + /* or any other list style */ } li::marker { - color: var(--main-bullet-color); + color: var(--main-bullet-color); + /* Change this to your desired color */ } a { - color: var(--main-link-color); - text-decoration: underline; - text-decoration-color: rgba(0, 0, 0, 0); - text-underline-offset: 2px; -} - -a { - transition: color 0.15s ease, text-decoration-color 0.15s ease; + color: var(--main-link-color); } a:hover { - text-decoration-color: var(--main-placeholder-color); - color: var(--main-bullet-color); + 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-text-opaque-color); - border-left: 3px solid var(--orange-rgb); - 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); - opacity: 0.5; -} - -time { - color: var(--main-bullet-color); - font-family: monospace; -} - -.post-list-date { - font-size: 0.95rem; + 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: - ui-monospace, - SFMono-Regular, - SF Mono, - Menlo, - Consolas, - Liberation Mono, - monospace; - font-size: 0.95em; + white-space: pre; + word-wrap: break-word; + overflow-wrap: break-word; + font-family: 'Fira Code', 'Consolas', 'Courier New', monospace; + font-size: 0.95em; } pre { - background-color: #1d1d1d; - color: #d5d5d5; - padding: 1em; - border-radius: 5px; - line-height: 1.5; - overflow-x: auto; + background-color: #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; - 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; + padding: 0.2em 0.4em; + border-radius: 3px; + background-color: #1d1d1d; + color: #d5d5d5; + font-size: 0.85em; } /* 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; + /* Optional: add some spacing around the image container */ } .image-container img { - max-width: 100%; - width: auto; - max-height: 100%; - height: auto; -} - -.fixed .image-container img { - max-width: 308px; - max-height: 308px; + /* 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: - 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; + 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); + margin-top: 0.5rem; + font-size: 0.8rem; + color: var(--main-small-text-color); } + + diff --git a/src/zona/data/server/inject.js b/src/zona/data/server/inject.js deleted file mode 100644 index bc00dde..0000000 --- a/src/zona/data/server/inject.js +++ /dev/null @@ -1,25 +0,0 @@ -(() => { - // if user at the bottom before reload, scroll to new bottom - if (localStorage.getItem("wasAtBottom") === "1") { - localStorage.removeItem("wasAtBottom"); - window.addEventListener("load", () => { - requestAnimationFrame(() => { - window.scrollTo(0, document.body.scrollHeight); - }); - }); - } - - const ws = new WebSocket("__SOCKET_ADDRESS__"); - const tol = __SCROLL_TOLERANCE__; - ws.onmessage = event => { - if (event.data === "reload") { - // store flag if user currently at bottom - const nearBottom = window.innerHeight + window.scrollY - >= document.body.scrollHeight - tol; - if (nearBottom) { - localStorage.setItem("wasAtBottom", "1"); - } - location.reload(); - } - }; -})(); diff --git a/src/zona/data/templates/basic.html b/src/zona/data/templates/basic.html index d86a25a..033d9e3 100644 --- a/src/zona/data/templates/basic.html +++ b/src/zona/data/templates/basic.html @@ -2,7 +2,7 @@ {% block content %} {% if metadata.show_title %} -{% include "title.html" %} +

{{ metadata.title }}

{% endif %} {{ content | safe }} {% endblock %} diff --git a/src/zona/data/templates/page.html b/src/zona/data/templates/page.html index 0ff499b..3e835b4 100644 --- a/src/zona/data/templates/page.html +++ b/src/zona/data/templates/page.html @@ -1,12 +1,13 @@ -{% extends "base.html" %} {% block content %} {% if metadata.show_title %} {% -include "title.html" %} {% if metadata.date.date() %} -
- -
-{% endif %} {% endif %} {% if is_post %} {% include "post_nav.html" %} {% endif -%} -
+{% extends "base.html" %} +{% block content %} +{% if metadata.show_title %} +

{{ metadata.title }}

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

{{ metadata.title }}

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

{{ metadata.title }}

- diff --git a/src/zona/layout.py b/src/zona/layout.py index f4c7311..3526c4e 100644 --- a/src/zona/layout.py +++ b/src/zona/layout.py @@ -1,6 +1,5 @@ from dataclasses import asdict, dataclass from pathlib import Path -from zoneinfo import ZoneInfo import typer import yaml @@ -17,22 +16,6 @@ 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( @@ -48,8 +31,6 @@ class Layout: output=(root / "public").resolve() if not output else output, - shared_templates=None, - _validate=validate, ) if validate: logger.debug("Validating site layout...") @@ -58,29 +39,10 @@ class Layout: raise FileNotFoundError( "Missing required content directory!" ) - internal_templates = util.get_resource_dir("templates") - user_templates = layout.templates - if not user_templates.is_dir() or util.is_empty( - user_templates - ): + if not layout.templates.is_dir(): logger.debug("Using default template directory.") # use the included defaults - layout.templates = internal_templates - else: - seen: set[str] = set() - temp = util.TempDir() - logger.debug( - f"Creating shared template directory at {temp}" - ) - for f in user_templates.iterdir(): - if f.is_file(): - util.copy_static_file(f, temp.path) - seen.add(f.name) - for f in internal_templates.iterdir(): - if f.is_file() and f.name not in seen: - util.copy_static_file(f, temp.path) - layout.shared_templates = temp - layout.templates = temp.path + layout.templates = util.get_resource_dir("templates") return layout @@ -125,8 +87,7 @@ def initialize_site(root: Path | None = None): layout = Layout.from_input(root=root, validate=False) # load template resources logger.debug("Loading internal templates.") - # only write the footer - templates = [util.get_resource("templates/footer.md")] + templates = util.get_resources("templates") logger.debug("Loading internal static content.") static = util.get_resources("content") for dir, resources in [ @@ -147,13 +108,9 @@ 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( - config_dict, + asdict(config), f, sort_keys=False, default_flow_style=False, diff --git a/src/zona/markdown.py b/src/zona/markdown.py index 1132f22..ce73016 100644 --- a/src/zona/markdown.py +++ b/src/zona/markdown.py @@ -1,10 +1,11 @@ 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 @@ -33,6 +34,8 @@ 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.""" @@ -40,7 +43,6 @@ 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): @@ -85,7 +87,6 @@ class ZonaLinkTreeprocessor(Treeprocessor): ): super().__init__() self.resolve: bool = resolve - self.logger: Logger = get_logger() if self.resolve: assert source is not None assert layout is not None @@ -102,15 +103,9 @@ class ZonaLinkTreeprocessor(Treeprocessor): if not href: continue if self.resolve: - assert self.config cur = Path(href) _href = href - same_file = False - resolved = Path() - # href starting with anchor reference the current file - if href.startswith("#"): - same_file = True - elif href.startswith("/"): + if href.startswith("/"): # resolve relative to content root resolved = ( self.layout.content / cur.relative_to("/") @@ -118,36 +113,20 @@ 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 internal: + if resolved.exists(): item = self.item_map.get(resolved) if item: href = util.normalize_url(item.url) - # don't sub if it's already correct lol - if _href != href: - element.set("href", href) - self.logger.debug( - f"Link in file {self.source}: {_href} resolved to {href}" - ) + element.set("href", href) + logger.debug( + f"Link in file {self.source}: {_href} resolved to {href}" + ) else: - self.logger.debug( + logger.debug( f"Warning: resolved path {resolved} not found in item map" ) - # open link in new tab if not self-link - elif ( - self.config.markdown.links.external_new_tab - and not same_file - ): - element.set("target", "_blank") + element.set("target", "_blank") def get_formatter(config: ZonaConfig): @@ -176,9 +155,7 @@ def md_to_html( FootnoteExtension(), AttrListExtension(), DefListExtension(), - TocExtension( - anchorlink=True, - ), + TocExtension(), TableExtension(), AbbrExtension(), SmartyExtension(), diff --git a/src/zona/metadata.py b/src/zona/metadata.py index 4551117..b079266 100644 --- a/src/zona/metadata.py +++ b/src/zona/metadata.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from datetime import date, datetime, time, tzinfo +from datetime import date from pathlib import Path import frontmatter @@ -10,50 +10,31 @@ 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: datetime - description: str + 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 template: str | None = None post: bool | None = None draft: bool = False - ignore: bool = False math: bool = True -def ensure_timezone(dt: datetime, tz: tzinfo) -> datetime: - if dt.tzinfo is None or dt.utcoffset() is None: - dt = dt.replace(tzinfo=tz) - return dt - - -# TODO: migrate to using datetime, where user can optionall specify -# a time as well. if only date is given, default to time.min -def parse_date( - raw_date: str | datetime | date | object, tz: tzinfo -) -> datetime: - if isinstance(raw_date, datetime): - return ensure_timezone(raw_date, tz) - elif isinstance(raw_date, date): - return datetime.combine(raw_date, time.min, tzinfo=tz) +def parse_date(raw_date: str | date | object) -> date: + if isinstance(raw_date, date): + return raw_date assert isinstance(raw_date, str) - dt = date_parser.parse(raw_date) - return ensure_timezone(dt, tz) + return date_parser.parse(raw_date).date() -def parse_metadata( - path: Path, config: ZonaConfig -) -> tuple[Metadata, str]: +def parse_metadata(path: Path) -> tuple[Metadata, str]: """ Parses a file and returns parsed Metadata and its content. Defaults are applied for missing fields. If there is no metadata, a Metadata @@ -69,11 +50,10 @@ def parse_metadata( raw_meta = post.metadata or {} defaults = { "title": zona.util.filename_to_title(path), - "date": datetime.fromtimestamp(path.stat().st_ctime), - "description": config.blog.defaults.description, + "date": date.fromtimestamp(path.stat().st_ctime), } meta = {**defaults, **raw_meta} - meta["date"] = parse_date(meta.get("date"), config.feed.timezone) + meta["date"] = parse_date(meta.get("date")) try: metadata = from_dict( data_class=Metadata, diff --git a/src/zona/models.py b/src/zona/models.py index 7f67015..2e7721c 100644 --- a/src/zona/models.py +++ b/src/zona/models.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from dataclasses import dataclass from enum import Enum from pathlib import Path @@ -23,8 +21,6 @@ class Item: type: ItemType | None = None copy: bool = True post: bool = False - newer: Item | None = None - older: Item | None = None # @dataclass diff --git a/src/zona/server.py b/src/zona/server.py index f8b0000..8e08b0c 100644 --- a/src/zona/server.py +++ b/src/zona/server.py @@ -2,7 +2,6 @@ import io import os import signal import sys -import tempfile import threading from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -13,7 +12,6 @@ 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 @@ -21,23 +19,18 @@ from zona.websockets import WebSocketServer logger = get_logger() -def make_reload_script( - host: str, port: int, scroll_tolerance: int -) -> str: +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.""" - js = util.get_resource("server/inject.js").contents - js = util.minify_js(js) - address = f"ws://{host}:{port}" - for placeholder, value in ( - ("__SOCKET_ADDRESS__", address), - ("__SCROLL_TOLERANCE__", scroll_tolerance), - ): - if placeholder not in js: - raise ValueError( - f"{placeholder} missing from reload script template!" - ) - js = js.replace(placeholder, str(value)) - return f"" + return f""" + +""" def make_handler_class(script: str): @@ -174,87 +167,73 @@ class ZonaReloadHandler(FileSystemEventHandler): def serve( root: Path | None = None, output: Path | None = None, - draft: bool = True, + draft: bool = False, host: str = "localhost", port: int = 8000, - user_reload: bool | None = None, + live_reload: bool = True, ): """Serve preview website with live reload and automatic rebuild.""" - # create temp dir, automatic cleanup - with tempfile.TemporaryDirectory() as tmp: - builder = ZonaBuilder(root, Path(tmp), draft) - config = builder.config - # initial site build - builder.build() - # use discovered paths if none provided - if output is None: - output = builder.layout.output - if root is None: - root = builder.layout.root + builder = ZonaBuilder(root, output, 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 - # use config value unless overridden by user - reload = config.server.reload.enabled - if user_reload is not None: - reload = user_reload - if reload: - print("Live reloading is enabled.") - # spin up websocket server for live reloading - ws_port = port + 1 - ws_server = WebSocketServer(host, ws_port) - ws_server.start() - # generate reload script for injection - scroll_tolerance = config.server.reload.scroll_tolerance - reload_script = make_reload_script( - host, ws_port, scroll_tolerance - ) - # generate handler with reload script as attribute - handler = make_handler_class(reload_script) - else: - handler = QuietHandler - ws_server = None - # serve the output directory - os.chdir(output) - # initialize http server - httpd = ZonaServer( - server_address=(host, port), RequestHandlerClass=handler - ) - # link websocket server - if ws_server: - httpd.set_ws_server(ws_server) - # provide link to user - print(f"Serving {output} at http://{host}:{port}") - print("Exit with ") + # 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 - ) - templates = root / "templates" - if templates.is_dir(): - observer.schedule( - event_handler, - path=str(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): - print("Shutting down...") - observer.stop() - httpd.shutdown() + # 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) + # 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() diff --git a/src/zona/templates.py b/src/zona/templates.py index 4529143..872762c 100644 --- a/src/zona/templates.py +++ b/src/zona/templates.py @@ -3,7 +3,6 @@ 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 @@ -27,6 +26,7 @@ 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__( @@ -35,7 +35,6 @@ 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"]), @@ -77,12 +76,5 @@ class Templater: metadata=meta, header=header, footer=footer, - is_post=item.post, - newer=util.normalize_url(item.newer.url) - if item.newer - else None, - older=util.normalize_url(item.older.url) - if item.older - else None, post_list=self.post_list, ) diff --git a/src/zona/util.py b/src/zona/util.py index 5bf6f53..7e80500 100644 --- a/src/zona/util.py +++ b/src/zona/util.py @@ -1,36 +1,10 @@ import fnmatch -import re -import shutil import string -import tempfile -import weakref from importlib import resources from importlib.resources.abc import Traversable from pathlib import Path from shutil import copy2 -from typing import Any, NamedTuple, override - - -class TempDir: - """Temporary directory that cleans up when it's garbage collected.""" - - def __init__(self): - self._tempdir: str = tempfile.mkdtemp() - self.path: Path = Path(self._tempdir) - self._finalizer: weakref.finalize[Any, Any] = ( - weakref.finalize(self, shutil.rmtree, self._tempdir) - ) - - def remove(self): - self._finalizer() - - @property - def removed(self): - return not self._finalizer.alive - - @override - def __repr__(self) -> str: - return f"" +from typing import NamedTuple class ZonaResource(NamedTuple): @@ -38,17 +12,6 @@ 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] = [] @@ -90,15 +53,6 @@ def copy_static_file(src: Path, dst: Path): copy2(src, dst) -def is_empty(path: Path) -> bool: - """If given a file, check if it has any non-whitespace content. - If given a directory, check if it has any children.""" - if path.is_file(): - return path.read_text().strip() == "" - else: - return not any(path.iterdir()) - - def filename_to_title(path: Path) -> str: name = path.stem words = name.replace("-", " ").replace("_", " ") @@ -119,23 +73,3 @@ def should_ignore( fnmatch.fnmatch(str(rel_path), pattern) for pattern in patterns ) - - -MINIFY_JS_PATTERN = re.compile( - r""" - //.*?$ | - /\*.*?\*/ | - \s+ - """, - re.MULTILINE | re.DOTALL | re.VERBOSE, -) - - -def minify_js(js: str) -> str: - """Naively minifies JavaScript by stripping comments and whitespace.""" - return MINIFY_JS_PATTERN.sub( - # replace whitespace with single space, - # strip comments - lambda m: " " if m.group(0).isspace() else "", - js, - ).strip() diff --git a/uv.lock b/uv.lock index c81743b..311053e 100644 --- a/uv.lock +++ b/uv.lock @@ -44,16 +44,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/35/386550fd60316d1e37eccdda609b074113298f23cef5bddb2049823fe666/dacite-1.9.2-py3-none-any.whl", hash = "sha256:053f7c3f5128ca2e9aceb66892b1a3c8936d02c686e707bee96e19deef4bc4a0", size = 16600, upload-time = "2025-02-05T09:27:24.345Z" }, ] -[[package]] -name = "feedgen" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "lxml" }, - { name = "python-dateutil" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6b/59/be0a6f852b5dfbf19e6c8e962c8f41407697f9f52a7902250ed98683ae89/feedgen-1.0.0.tar.gz", hash = "sha256:d9bd51c3b5e956a2a52998c3708c4d2c729f2fcc311188e1e5d3b9726393546a", size = 258496, upload-time = "2023-12-25T18:04:08.421Z" } - [[package]] name = "iniconfig" version = "2.1.0" @@ -97,46 +87,6 @@ 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" @@ -509,11 +459,10 @@ wheels = [ [[package]] name = "zona" -version = "1.2.1" +version = "0.1.0" source = { editable = "." } dependencies = [ { name = "dacite" }, - { name = "feedgen" }, { name = "jinja2" }, { name = "l2m4m" }, { name = "markdown" }, @@ -540,7 +489,6 @@ 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" },