Compare commits

...

34 commits
v1.0.0 ... main

Author SHA1 Message Date
66a3eb7f8c release: 1.2.2
Some checks failed
/ publish (push) Successful in 19s
/ test-build (push) Failing after 27s
2025-07-18 00:42:45 -04:00
182b30a4ef fix: rss feed is no longer written to content directory
Some checks failed
/ test-build (push) Failing after 19s
2025-07-18 00:41:04 -04:00
312818d8a6 doc: updated changelog
Some checks failed
/ test-build (push) Failing after 18s
2025-07-18 00:38:32 -04:00
d8d1e991c2 feat: init now only includes footer template
Some checks failed
/ test-build (push) Failing after 25s
2025-07-18 00:23:51 -04:00
828a82e5cb feat: rss feed generation
Some checks failed
/ test-build (push) Failing after 18s
2025-07-18 00:19:05 -04:00
4f8979ae9b fixed incorrect codeberg links in readme 2025-07-17 23:52:47 -04:00
ac8c88e2af ci: updated publish job to use cache
All checks were successful
/ test-build (push) Successful in 20s
2025-07-16 01:37:50 -04:00
59948ec517 updated changelog
All checks were successful
/ publish (push) Successful in 15s
/ test-build (push) Successful in 18s
2025-07-16 01:35:19 -04:00
dea02e3a4e ci: updated test to check version
All checks were successful
/ test-build (push) Successful in 21s
2025-07-16 01:33:53 -04:00
3dc623f1c7 added --version flag to cli
All checks were successful
/ test-build (push) Successful in 37s
2025-07-16 01:32:59 -04:00
4cef77e4e0 ci: added test with cache
All checks were successful
/ test-build (push) Successful in 21s
2025-07-16 01:17:23 -04:00
8ad8bad438 updated styling of lists 2025-07-15 23:24:06 -04:00
a0fb62ac7a release 1.2.0
All checks were successful
/ publish (push) Successful in 22s
- 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.
2025-07-15 18:46:25 -04:00
d35d3f61fb updated documentation 2025-07-15 18:42:32 -04:00
4ea80a33f8 improved styling of hover symbols 2025-07-15 18:35:13 -04:00
c33acfa1c8 added hover symbols to page titles 2025-07-15 17:53:05 -04:00
bafe70ed37 fix post-nav button order
Navigation now follows newer/older logic. click right to go older, left
to go newer.
2025-07-15 17:18:09 -04:00
fa10a813f2 add to .kakrc 2025-07-15 16:48:03 -04:00
c6dd2785af added .kakrc to manage cwd in editor 2025-07-15 15:13:27 -04:00
f72142fff3 prepare release
All checks were successful
/ publish (push) Successful in 32s
2025-07-14 22:57:36 -04:00
115751b120 fix: mistake in post-nav template 2025-07-14 22:50:26 -04:00
1f2b736815 feat: merge user templates with defaults 2025-07-14 21:24:22 -04:00
c489a6f076 added post-nav to posts 2025-07-14 20:15:40 -04:00
933210c93b fixed crash on missing templates dir when starting server 2025-07-14 19:19:58 -04:00
404e951651 added smooth scroll to default stylesheet 2025-07-14 18:57:51 -04:00
24919171ad fixed structure of page date in templates 2025-07-14 16:34:17 -04:00
de86a92928 added title styling to default stylesheet 2025-07-14 16:24:28 -04:00
2bc12ff2ed refactored title in templates
now has "title" class for easier styling
2025-07-14 16:24:14 -04:00
07152a3746 feat: ignore frontmatter option 2025-07-14 16:11:47 -04:00
27178dc6d8 updated formatter config 2025-07-14 16:06:24 -04:00
04948a9373 formatted python files 2025-07-14 16:04:05 -04:00
faac8f63bb major updates to default stylesheet 2025-07-14 15:00:54 -04:00
e28a206067 update publish workflow to use based-alpine 2025-07-13 23:42:51 -04:00
8b849fd2a0 ci: added pypi release workflow 2025-07-13 22:10:37 -04:00
22 changed files with 738 additions and 123 deletions

View file

@ -0,0 +1,20 @@
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: based-alpine
steps:
- uses: actions/checkout@v4
- name: setup cache
id: uv-cache
uses: https://git.ficd.sh/ficd/uv-cache@v1
- name: build
run: |
uv sync
uv build
- name: publish
run: |
uv publish --token ${{ secrets.PYPI_TOKEN }}

View file

@ -0,0 +1,18 @@
# this workflow checks if the project can be built successfully.
# it also serves to test whether based-alpine and uv-cache are working properly.
# Unit tests will be added here eventually
on: [push]
jobs:
test-build:
runs-on: based-alpine
steps:
- name: checkout source
uses: actions/checkout@v4
- name: setup cache
id: uv-cache
uses: https://git.ficd.sh/ficd/uv-cache@v1
- name: sync and build
run: |
uv sync
uv build
uv run zona --version

25
.kakrc Normal file
View file

@ -0,0 +1,25 @@
# commands to edit important files in the root
declare-option str project_root %sh{ git rev-parse --show-toplevel }
define-command -params 1 root-edit %{
edit %exp{%opt{project_root}/%arg{1}}
}
define-command just %{
root-edit justfile
}
define-command pyproject %{
root-edit pyproject.toml
}
define-command readme %{
root-edit README.md
}
define-command kakrc %{
root-edit .kakrc
}
# change working directory to the package
hook global -once BufCreate .* %{
change-directory %exp{%opt{project_root}/src/zona}
}

33
CHANGELOG.md Normal file
View file

@ -0,0 +1,33 @@
# 1.3.0
- Added RSS feed generation.
- Added default post description to configuration.
- Added time-of-day support to post `date` frontmatter parsing.
- `zona init` now only writes `footer.md` to the templates directory.
# 1.2.1
- Added `--version` flag to CLI.
# 1.2.0
- Improved the appearance and semantics of post navigation buttons.
- Navigation now follows "newer/older" logic.
- Added hover symbols to page titles.
- Improved the styling of hover symbols and links.
# 1.1.0
- Major improvements to default stylesheet.
- Frontmatter option to ignore file.
- Improvements to title and date rendering in templates.
- Added smooth scrolling to default stylesheet.
- Fixed a crash when user templates directory was missing when starting the
server.
- Added "next/previous" navigation buttons to posts.
- User template directory is now merged with defaults instead of it being one or
the other.
# 1.0.0
Initial release!

110
README.md
View file

@ -28,6 +28,7 @@ For an example of a website built with zona, please see
- [Site Layout](#site-layout) - [Site Layout](#site-layout)
- [Templates](#templates) - [Templates](#templates)
- [Markdown Footer](#markdown-footer) - [Markdown Footer](#markdown-footer)
- [RSS Feed Generation](#rss-feed-generation)
- [Internal Link Resolution](#internal-link-resolution) - [Internal Link Resolution](#internal-link-resolution)
- [Syntax Highlighting](#syntax-highlighting) - [Syntax Highlighting](#syntax-highlighting)
- [Markdown Extensions](#markdown-extensions) - [Markdown Extensions](#markdown-extensions)
@ -49,6 +50,7 @@ For an example of a website built with zona, please see
- Live refresh in browser preview. - Live refresh in browser preview.
- `jinja2` template support with sensible defaults included. - `jinja2` template support with sensible defaults included.
- Basic page, blog post, post list. - Basic page, blog post, post list.
- RSS feed generation.
- Glob ignore. - Glob ignore.
- YAML frontmatter. - YAML frontmatter.
- Easily configurable sitemap header. - Easily configurable sitemap header.
@ -101,10 +103,6 @@ If you don't want discovery, you can specify the project root as the first
argument to `zona build`. You may specify a path for the output using the 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 ### Live Preview
To make the writing process as frictionless as possible, zona ships with a live To make the writing process as frictionless as possible, zona ships with a live
@ -121,9 +119,10 @@ By default, the build outputs to a temporary directory. Use `-o/--output` to
override this. override this.
**Note**: if the live preview isn't working as expected, try restarting the **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 server. If you change the configuration, the server must also be restarted. The
be restarted. The live preview uses the same function as `zona build` live preview uses the same function as `zona build` internally; this means that
internally; this means that the output is also written to disk. the output is also written to disk --- a temporary directory by default, unless
overridden with `-o/--output`.
#### Live Reload #### Live Reload
@ -172,8 +171,8 @@ public/
The **root** of the zona **project** _must_ contain the configuration file, The **root** of the zona **project** _must_ contain the configuration file,
`config.yml`, and a directory called `content`. A directory called `templates` `config.yml`, and a directory called `content`. A directory called `templates`
is optional, and prioritized if it exists. `public` is the built site output — is optional, and merged with the defaults if it exists. `public` is the built
it's recommended to add this path to your `.gitignore`. 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 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 root**. For example, suppose your website is hosted at `example.com`.
@ -191,17 +190,22 @@ site using the `post_list` template.
### Templates ### Templates
The `templates` directory may contain any `jinja2` template files. You may The `templates` directory may contain any `jinja2` template files. You may
modify the existing templates or create your own. To apply a certain template to modify the existing templates or create your own. Your templates are merged with
a page, set the `template` option in its [frontmatter](#frontmatter). The the packaged defaults. To apply a certain template to a page, set the `template`
following public variables are made available to the template engine: option in its [frontmatter](#frontmatter). The following public variables are
made available to the template engine:
| Name | Description | | Name | Description |
| ---------- | ------------------------------------------------------ | | ----------- | -------------------------------------------------------- |
| `content` | The content of this page. | | `content` | The content of this page. |
| `url` | The resolved URL of this page. | | `url` | The resolved URL of this page. |
| `metadata` | The frontmatter of this page (_merged with defaults_). | | `metadata` | The frontmatter of this page (_merged with defaults_). |
| `header` | The sitemap header in HTML form. Can be `False`. | | `header` | The sitemap header in HTML form. Can be `False`. |
| `footer` | The footer in HTML form. Can be `False`. | | `footer` | The footer in HTML form. Can be `False`. |
| `is_post` | Whether this page is a post. |
| `newer` | URL of the newer post in the post list. |
| `older` | URL of the older post in the post list. |
| `post_list` | A sorted list of `Item` objects. Meant for internal use. |
#### Markdown Footer #### Markdown Footer
@ -210,6 +214,17 @@ it's parsed and rendered into HTML, then made available to other templates as
the `footer` variable. If `footer.md` is missing but `footer.html` exists, then 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.** it's used instead. **Note: links are _not_ resolved in the footer.**
### RSS Feed Generation
Zona can also generates an RSS feed containing your blog posts. This feature is
disabled by default, and you can enable it in the
[configuration](#configuration).
The default location is a file called `rss.xml` in the content root. All RSS
related configuration is specified in `config.yml`. If you plan to use the feed,
make sure to replace the placeholder `link`, `title`, `description`, and
`author` configuration values before you set `enabled: true`.
### Internal Link Resolution ### Internal Link Resolution
When zona encounters links in Markdown documents, it attempts to resolve them as When zona encounters links in Markdown documents, it attempts to resolve them as
@ -229,11 +244,11 @@ modified if they point to a real file that's not included in the ignore list.
Zona uses [Pygments] to provide syntax highlighting for fenced code blocks. The Zona uses [Pygments] to provide syntax highlighting for fenced code blocks. The
following Pygments plugins are included: following Pygments plugins are included:
- [pygments-kakoune](https://codeberg.com/ficd/pygments-kakoune) - [pygments-kakoune](https://codeberg.org/ficd/pygments-kakoune)
- A lexer providing for highlighting Kakoune code. Available under the `kak` - A lexer providing for highlighting Kakoune code. Available under the `kak`
and `kakrc` aliases. and `kakrc` aliases.
- [pygments-ashen](https://codeberg.com/ficd/ashen/tree/main/item/pygments/README.md) - [pygments-ashen](https://codeberg.org/ficd/ashen/tree/main/item/pygments/README.md)
- An implementation of the [Ashen](https://codeberg.com/ficd/ashen) theme for - An implementation of the [Ashen](https://codeberg.org/ficd/ashen) theme for
Pygments. Pygments.
If you want to use any external Pygments styles or lexers, they must be If you want to use any external Pygments styles or lexers, they must be
@ -307,17 +322,19 @@ YAML frontmatter can be used to configure the metadata of documents. All of them
are optional. `none` is used when the option is unset. The following options are are optional. `none` is used when the option is unset. The following options are
available: available:
| Key | Type & Default | Description | | Key | Type & Default | Description |
| ------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------ | | ------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `title` | `str` = title-cased filename. | Title of the page. | | `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. | | `description` | `str \| none` = `none` | Description. If omitted, default from [config](#configuration) will be used. |
| `show_title` | `bool` = `true` | Whether `metadata.title` should be included in the template. | | `date` | Date string = file modified time. | Displayed on blog posts and used for post_list sorting. |
| `header` | `bool` = `true` | Whether the header sitemap should be rendered. | | `show_title` | `bool` = `true` | Whether `metadata.title` should be included in the template. |
| `footer` | `bool` = `true` | Whether the footer should be rendered. | | `header` | `bool` = `true` | Whether the header sitemap should be rendered. |
| `template` | `str \| none` = `none` | Template to use for this page. Relative to `templates/`, `.html` extension optional. | | `footer` | `bool` = `true` | Whether the footer should be rendered. |
| `post` | `bool \| none` = `none` | Whether this page is a **post**. `true`/`false` is _absolute_. Leave it unset for automatic detection. | | `template` | `str \| none` = `none` | Template to use for this page. Relative to `templates/`, `.html` extension optional. |
| `draft` | `bool` = `false` | Whether this page is a draft. See [drafts](#drafts) for more. | | `post` | `bool \| none` = `none` | Whether this page is a **post**. `true`/`false` is _absolute_. Leave it unset for automatic detection. |
| `math` | `bool` = `true` | Whether the LaTeX extension should be enabled for this page. | | `draft` | `bool` = `false` | Whether this page is a draft. See [drafts](#drafts) for more. |
| `ignore` | `bool` = `false` | Whether this page should be ignored in _both_ `final` and `draft` contexts. |
| `math` | `bool` = `true` | Whether the LaTeX extension should be enabled for this page. |
**Note**: you can specify the date in any format that can be parsed by **Note**: you can specify the date in any format that can be parsed by
[`python-dateutil`](https://pypi.org/project/python-dateutil/). [`python-dateutil`](https://pypi.org/project/python-dateutil/).
@ -371,12 +388,23 @@ useful settings are listed here.
Please see the default configuration: Please see the default configuration:
```yaml ```yaml
base_url: / feed:
enabled: true
timezone: UTC
path: rss.xml
link: https://example.com
title: Zona Website
description: My zona website.
language: en
author:
name: John Doe
email: john@doe.net
sitemap: sitemap:
Home: / Home: /
ignore: ignore:
- .marksman.toml - .marksman.toml
markdown: markdown:
image_labels: true
tab_length: 2 tab_length: 2
syntax_highlighting: syntax_highlighting:
enabled: true enabled: true
@ -389,6 +417,8 @@ build:
include_drafts: false include_drafts: false
blog: blog:
dir: blog dir: blog
defaults:
description: A blog post
server: server:
reload: reload:
enabled: true enabled: true
@ -397,6 +427,15 @@ server:
| Name | Description | | 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). | | `sitemap` | Sitemap dictionary. See [Sitemap](#sitemap). |
| `ignore` | List of paths to ignore. See [Ignore List](#ignore-list). | | `ignore` | List of paths to ignore. See [Ignore List](#ignore-list). |
| `markdown.tab_length` | How many spaces should be considered an indentation level. | | `markdown.tab_length` | How many spaces should be considered an indentation level. |
@ -407,6 +446,7 @@ server:
| `build.clean_output_dir` | Whether previous build artifacts should be cleared when building. Recommended to leave this on. | | `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. | | `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.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.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". | | `server.reload.scroll_tolerance` | The distance, in pixels, from the bottom to still count as "scrolled to bottom". |
@ -439,7 +479,7 @@ output. If you set `draft: true` in a page's frontmatter, it will be marked as a
draft. Drafts are completely excluded from `zona build` and `zona serve` unless draft. Drafts are completely excluded from `zona build` and `zona serve` unless
the `--draft` flag is specified. the `--draft` flag is specified.
[Ashen]: https://codeberg.com/ficd/ashen [Ashen]: https://codeberg.org/ficd/ashen
[Pygments]: https://pygments.org/ [Pygments]: https://pygments.org/
## Known Problems ## Known Problems

View file

@ -1,6 +1,6 @@
[project] [project]
name = "zona" name = "zona"
version = "1.0.0" version = "1.2.2"
description = "Opinionated static site generator." description = "Opinionated static site generator."
license = "BSD-3-Clause " license = "BSD-3-Clause "
license-files = ["LICENSE"] license-files = ["LICENSE"]
@ -11,6 +11,7 @@ authors = [
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"dacite>=1.9.2", "dacite>=1.9.2",
"feedgen>=1.0.0",
"jinja2>=3.1.6", "jinja2>=3.1.6",
"l2m4m>=1.0.4", "l2m4m>=1.0.4",
"markdown>=3.8.2", "markdown>=3.8.2",
@ -57,12 +58,12 @@ reportUnusedCallResult = false
reportCallInDefaultInitializer = false reportCallInDefaultInitializer = false
enableTypeIgnoreComments = true enableTypeIgnoreComments = true
reportIgnoreCommentWithoutRule = false reportIgnoreCommentWithoutRule = false
allowedUntypedLibraries = ["frontmatter", "pygments", "pymdownx", "l2m4m"] allowedUntypedLibraries = ["frontmatter", "pygments", "pymdownx", "l2m4m", "feedgen", "feedgen.feed"]
[tool.ruff] [tool.ruff]
line-length = 70 line-length = 70
indent-width = 4 indent-width = 4
target-version = "py311" target-version = "py312"
[tool.ruff.lint] [tool.ruff.lint]
fixable = ["ALL"] fixable = ["ALL"]

View file

@ -2,6 +2,8 @@ import shutil
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
from feedgen.feed import FeedGenerator
from zona import markdown as zmd from zona import markdown as zmd
from zona import util from zona import util
from zona.config import ZonaConfig from zona.config import ZonaConfig
@ -30,6 +32,7 @@ class ZonaBuilder:
self.config.build.include_drafts = True self.config.build.include_drafts = True
self.items: list[Item] = [] self.items: list[Item] = []
self.item_map: dict[Path, Item] = {} self.item_map: dict[Path, Item] = {}
self.fresh: bool = True
def _discover(self): def _discover(self):
layout = self.layout layout = self.layout
@ -48,12 +51,16 @@ class ZonaBuilder:
destination=destination, destination=destination,
url=str(destination.relative_to(layout.output)), 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" layout.root / "content" / "static"
): ):
logger.debug(f"Parsing {path.name}.") logger.debug(f"Parsing {path.name}.")
item.metadata, item.content = parse_metadata(path) item.metadata, item.content = parse_metadata(
if ( path, config=self.config
)
if item.metadata.ignore or (
item.metadata.draft item.metadata.draft
and not self.config.build.include_drafts and not self.config.build.include_drafts
): ):
@ -69,11 +76,13 @@ class ZonaBuilder:
item.copy = False item.copy = False
name = destination.stem name = destination.stem
if name == "index": if name == "index":
item.destination = item.destination.with_suffix( item.destination = (
".html" item.destination.with_suffix(".html")
) )
else: else:
relative = path.relative_to(base).with_suffix("") relative = path.relative_to(base).with_suffix(
""
)
name = relative.stem name = relative.stem
item.destination = ( item.destination = (
layout.output layout.output
@ -85,13 +94,55 @@ class ZonaBuilder:
layout.output layout.output
) )
item.url = ( item.url = (
"" if rel_url == Path(".") else rel_url.as_posix() ""
if rel_url == Path(".")
else rel_url.as_posix()
) )
items.append(item) items.append(item)
self.items = items self.items = items
def _build(self): def generate_feed(self) -> bytes:
post_list = self._get_post_list()
config = self.config.feed
if config.link.endswith("/"):
config.link = config.link[:-2]
fg = FeedGenerator()
fg.id(config.link)
fg.title(config.title)
author = {
"name": config.author.name,
"email": config.author.email,
}
fg.author(author)
fg.link(
href=f"{config.link}/{config.path}",
rel="self",
type="application/rss+xml",
)
fg.language(config.language)
fg.description(config.description)
for post in post_list:
assert post.metadata
fe = fg.add_entry() # pyright: ignore[reportUnknownVariableType]
fe.id(f"{config.link}{util.normalize_url(post.url)}") # pyright: ignore[reportUnknownMemberType]
fe.link( # pyright: ignore[reportUnknownMemberType]
href=f"{config.link}{util.normalize_url(post.url)}"
)
fe.title(post.metadata.title) # pyright: ignore[reportUnknownMemberType]
fe.author(author) # pyright: ignore[reportUnknownMemberType]
desc = post.metadata.description
fe.description(desc) # pyright: ignore[reportUnknownMemberType]
date = post.metadata.date
fe.pubDate(date) # pyright: ignore[reportUnknownMemberType]
out: bytes = fg.rss_str(pretty=True) # pyright: ignore[reportUnknownVariableType]
assert isinstance(out, bytes)
return out
def _get_post_list(self) -> list[Item]:
assert self.items assert self.items
# sort according to date
# descending order
post_list: list[Item] = sorted( post_list: list[Item] = sorted(
[item for item in self.items if item.post], [item for item in self.items if item.post],
key=lambda item: item.metadata.date key=lambda item: item.metadata.date
@ -99,6 +150,22 @@ class ZonaBuilder:
else date.min, else date.min,
reverse=True, 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( templater = Templater(
config=self.config, config=self.config,
template_dir=self.layout.templates, template_dir=self.layout.templates,
@ -111,7 +178,9 @@ class ZonaBuilder:
# write code highlighting stylesheet # write code highlighting stylesheet
if self.config.markdown.syntax_highlighting.enabled: if self.config.markdown.syntax_highlighting.enabled:
pygments_style = zmd.get_style_defs(self.config) 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) util.ensure_parents(pygments_path)
pygments_path.write_text(pygments_style) pygments_path.write_text(pygments_style)
for item in self.item_map.values(): for item in self.item_map.values():
@ -152,7 +221,15 @@ class ZonaBuilder:
child.unlink() child.unlink()
elif child.is_dir(): elif child.is_dir():
shutil.rmtree(child) shutil.rmtree(child)
if not self.fresh:
self.layout = self.layout.refresh()
logger.debug("Discovering...") logger.debug("Discovering...")
self._discover() self._discover()
logger.debug("Building...") logger.debug("Building...")
self._build() 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

View file

@ -1,3 +1,4 @@
from importlib.metadata import version as __version__
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated
@ -59,7 +60,9 @@ def build(
""" """
if draft: if draft:
print("Option override: including drafts.") print("Option override: including drafts.")
builder = ZonaBuilder(cli_root=root, cli_output=output, draft=draft) builder = ZonaBuilder(
cli_root=root, cli_output=output, draft=draft
)
builder.build() builder.build()
@ -73,7 +76,9 @@ def serve(
] = None, ] = None,
host: Annotated[ host: Annotated[
str, str,
typer.Option("--host", help="Hostname for live preview server."), typer.Option(
"--host", help="Hostname for live preview server."
),
] = "localhost", ] = "localhost",
port: Annotated[ port: Annotated[
int, int,
@ -130,8 +135,23 @@ def serve(
) )
def version_callback(value: bool):
if value:
print(f"Zona version: {__version__('zona')}")
raise typer.Exit()
@app.callback() @app.callback()
def main_entry( 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[ verbosity: Annotated[
str, str,
typer.Option( typer.Option(

View file

@ -1,7 +1,13 @@
from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, tzinfo
from pathlib import Path from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
import yaml import yaml
from dacite import Config as DaciteConfig
from dacite import from_dict from dacite import from_dict
from zona.log import get_logger from zona.log import get_logger
@ -25,9 +31,17 @@ def find_config(start: Path | None = None) -> Path | None:
SitemapConfig = dict[str, str] SitemapConfig = dict[str, str]
@dataclass
class PostDefaultsConfig:
description: str = "A blog post"
@dataclass @dataclass
class BlogConfig: class BlogConfig:
dir: str = "blog" dir: str = "blog"
defaults: PostDefaultsConfig = field(
default_factory=PostDefaultsConfig
)
@dataclass @dataclass
@ -69,14 +83,44 @@ class ServerConfig:
reload: ReloadConfig = field(default_factory=ReloadConfig) 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"] 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 @dataclass
class ZonaConfig: class ZonaConfig:
base_url: str = "/" base_url: str = "/"
feed: FeedConfig = field(default_factory=FeedConfig)
# dictionary where key is name, value is url # 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 # list of globs relative to content that should be ignored
ignore: list[str] = field(default_factory=lambda: IGNORELIST) ignore: list[str] = field(default_factory=lambda: IGNORELIST)
markdown: MarkdownConfig = field(default_factory=MarkdownConfig) markdown: MarkdownConfig = field(default_factory=MarkdownConfig)
@ -85,7 +129,12 @@ class ZonaConfig:
server: ServerConfig = field(default_factory=ServerConfig) server: ServerConfig = field(default_factory=ServerConfig)
@classmethod @classmethod
def from_file(cls, path: Path) -> "ZonaConfig": def from_file(cls, path: Path) -> ZonaConfig:
with open(path, "r") as f: with open(path, "r") as f:
raw = yaml.safe_load(f) raw = yaml.safe_load(f)
return from_dict(data_class=cls, data=raw) config: ZonaConfig = from_dict(
data_class=cls,
data=raw,
config=DaciteConfig(type_hooks={tzinfo: parse_timezone}),
)
return config

View file

@ -1,14 +1,22 @@
:root { :root {
--main-placeholder-color: #b14242;
--main-text-color: #b4b4b4; --main-text-color: #b4b4b4;
--main-text-opaque-color: rgba(180, 180, 180, 0.8);
--main-bg-color: #121212; --main-bg-color: #121212;
--main-link-color: #df6464; --main-link-color: #df6464;
--main-heading-color: #df6464; --main-heading-color: #df6464;
--main-bullet-color: #d87c4a; --main-bullet-color: #d87c4a;
--orange-rgb: rgba(216, 124, 74, 0.6);
--main-transparent: rgba(255, 255, 255, 0.15); --main-transparent: rgba(255, 255, 255, 0.15);
--main-small-text-color: rgba(255, 255, 255, 0.45); --main-small-text-color: rgba(255, 255, 255, 0.45);
} }
html {
scroll-behavior: smooth;
}
body { body {
margin: 0;
line-height: 1.6; line-height: 1.6;
font-size: 18px; font-size: 18px;
font-family: sans-serif; font-family: sans-serif;
@ -17,10 +25,110 @@ body {
padding-left: calc(100vw - 100%); 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 { .toclink {
position: relative; position: relative;
text-decoration: none; text-decoration: none;
color: inherit; 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 { .toclink::before {
@ -31,7 +139,16 @@ body {
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
opacity: 0; opacity: 0;
transition: opacity 0.2s ease; transition: opacity 0.15s ease, color 0.15s ease;
color: var(--main-link-color);
}
.toclink:hover::before {
opacity: 1;
color: var(--main-placeholder-color);
}
.toclink:hover {
background-color: transparent;
} }
h1 .toclink::before { h1 .toclink::before {
@ -50,13 +167,6 @@ h4 .toclink::before {
content: "###"; content: "###";
} }
.toclink:hover::before {
opacity: 1;
}
.toclink:hover {
background-color: transparent;
}
/* h1, */ /* h1, */
h2, h2,
h3, h3,
@ -73,6 +183,16 @@ h1 {
font-weight: bold; font-weight: bold;
} }
.title {
text-transform: lowercase;
font-family: monospace;
}
.title a {
color: inherit;
text-decoration: none;
}
article h1:first-of-type { article h1:first-of-type {
margin-block-start: 1.67rem; margin-block-start: 1.67rem;
} }
@ -116,23 +236,55 @@ h6 {
font-weight: bold; font-weight: bold;
} }
/*ul {*/
/* list-style-type: disc;*/
/*}*/
ul { ul {
list-style-type: disc; list-style-type: " ";
/* or any other list style */ }
ul ul {
padding-left: 1em;
margin-left: 1em;
list-style-type: "+ ";
}
ul ul ul {
list-style-type: "~ ";
}
ul ul ul ul {
list-style-type: "• ";
}
ul ul ul ul ul {
list-style-type: " ";
}
ul ul ul ul ul ul {
list-style-type: "+ ";
}
ul ul ul ul ul ul ul {
list-style-type: "~ ";
}
ul ul ul ul ul ul ul ul {
list-style-type: "• ";
} }
li::marker { li::marker {
color: var(--main-bullet-color); color: var(--main-bullet-color);
/* Change this to your desired color */
} }
a { a {
color: var(--main-link-color); color: var(--main-link-color);
text-decoration: underline; text-decoration: underline;
text-decoration-color: rgba(0, 0, 0, 0);
text-underline-offset: 2px;
}
a {
transition: color 0.15s ease, text-decoration-color 0.15s ease;
} }
a:hover { a:hover {
background: var(--main-transparent); text-decoration-color: var(--main-placeholder-color);
color: var(--main-bullet-color);
} }
max-width: 100%; max-width: 100%;
@ -146,8 +298,8 @@ img {
} }
blockquote { blockquote {
color: var(--main-small-text-color); color: var(--main-text-opaque-color);
border-left: 3px solid var(--main-transparent); border-left: 3px solid var(--orange-rgb);
padding: 0 1rem; padding: 0 1rem;
margin-left: 0; margin-left: 0;
margin-right: 0; margin-right: 0;
@ -157,6 +309,16 @@ hr {
border: none; border: none;
height: 1px; height: 1px;
background: var(--main-small-text-color); background: var(--main-small-text-color);
opacity: 0.5;
}
time {
color: var(--main-bullet-color);
font-family: monospace;
}
.post-list-date {
font-size: 0.95rem;
} }
code { code {
@ -183,7 +345,7 @@ pre {
} }
pre { pre {
background-color: #151515; background-color: #1d1d1d;
color: #d5d5d5; color: #d5d5d5;
padding: 1em; padding: 1em;
border-radius: 5px; border-radius: 5px;
@ -359,16 +521,3 @@ caption {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--main-small-text-color); color: var(--main-small-text-color);
} }
a > code {
text-decoration: none;
color: inherit;
}
a:has(> code) {
text-decoration: none;
}
a:hover > code {
background-color: var(--main-transparent);
}

View file

@ -2,7 +2,7 @@
{% block content %} {% block content %}
{% if metadata.show_title %} {% if metadata.show_title %}
<center><h1>{{ metadata.title }}</h1></center> {% include "title.html" %}
{% endif %} {% endif %}
{{ content | safe }} {{ content | safe }}
{% endblock %} {% endblock %}

View file

@ -1,13 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %} {% block content %} {% if metadata.show_title %} {%
include "title.html" %} {% if metadata.date.date() %}
<center>
<time class="post-date" datetime="{{ metadata.date.date() | safe }}"
>{{ metadata.date.date() | safe}}</time>
</center>
{% endif %} {% endif %} {% if is_post %} {% include "post_nav.html" %} {% endif
%}
<hr>
{% block content %}
{% if metadata.show_title %}
<center><h1>{{ metadata.title }}</h1></center>
{% endif %}
{% if metadata.date %}
<center><small><time datetime="{{ metadata.date | safe }}">{{ metadata.date | safe}}</time></small></center>
{% endif %}
<article>{{ content | safe }}</article> <article>{{ content | safe }}</article>
{% endblock %} {% endblock %}

View file

@ -1,17 +1,18 @@
{% extends "base.html" %} {% extends "base.html" %} {% block content %} {% if metadata.show_title %} {%
include "title.html" %} {% endif %}
{% block content %}
<center><h1>{{ metadata.title }}</h1></center>
<article>{{ content | safe }}</article> <article>{{ content | safe }}</article>
{% if post_list %} {% if post_list %}
<div class="post-list">
<ul> <ul>
{% for item in post_list %} {% for item in post_list %}
<li><small><time datetime="{{ item.metadata.date | safe }}">{{ item.metadata.date | safe}}</time></small>: <a href="/{{ item.url }}">{{ item.metadata.title }}</a></li> <li>
<time class="post-list-date" datetime="{{ item.metadata.date.date() | safe }}"
>{{ item.metadata.date.date() | safe}}</time>: <a href="/{{ item.url }}"
>{{ item.metadata.title }}</a>
</li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} </div>
{% endblock %} {% endif %} {% endblock %}

View file

@ -0,0 +1,11 @@
<div class="post-nav">
<center>
<span class="symbol">&lt;</span>{% if newer %}<a href="{{ newer }}">newr</a>{%
else %}<span class="placeholder">null</span>{% endif %}<span
class="symbol"
><span class="bar"></span></span>{% if older %}<a href="{{ older }}"
>oldr</a>{% else %}<span class="placeholder">null</span>{% endif %}<span
class="symbol"
>&gt;</span>
</center>
</div>

View file

@ -0,0 +1,2 @@
<center><h1 class="title hover-symbol"><a id="" href="#">{{ metadata.title }}</a></h1></center>

View file

@ -1,5 +1,6 @@
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo
import typer import typer
import yaml import yaml
@ -16,6 +17,22 @@ class Layout:
content: Path content: Path
templates: Path templates: Path
output: 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 @classmethod
def from_input( def from_input(
@ -31,6 +48,8 @@ class Layout:
output=(root / "public").resolve() output=(root / "public").resolve()
if not output if not output
else output, else output,
shared_templates=None,
_validate=validate,
) )
if validate: if validate:
logger.debug("Validating site layout...") logger.debug("Validating site layout...")
@ -39,10 +58,29 @@ class Layout:
raise FileNotFoundError( raise FileNotFoundError(
"Missing required content directory!" "Missing required content directory!"
) )
if not layout.templates.is_dir(): internal_templates = util.get_resource_dir("templates")
user_templates = layout.templates
if not user_templates.is_dir() or util.is_empty(
user_templates
):
logger.debug("Using default template directory.") logger.debug("Using default template directory.")
# use the included defaults # use the included defaults
layout.templates = util.get_resource_dir("templates") layout.templates = internal_templates
else:
seen: set[str] = set()
temp = util.TempDir()
logger.debug(
f"Creating shared template directory at {temp}"
)
for f in user_templates.iterdir():
if f.is_file():
util.copy_static_file(f, temp.path)
seen.add(f.name)
for f in internal_templates.iterdir():
if f.is_file() and f.name not in seen:
util.copy_static_file(f, temp.path)
layout.shared_templates = temp
layout.templates = temp.path
return layout return layout
@ -87,7 +125,8 @@ def initialize_site(root: Path | None = None):
layout = Layout.from_input(root=root, validate=False) layout = Layout.from_input(root=root, validate=False)
# load template resources # load template resources
logger.debug("Loading internal templates.") logger.debug("Loading internal templates.")
templates = util.get_resources("templates") # only write the footer
templates = [util.get_resource("templates/footer.md")]
logger.debug("Loading internal static content.") logger.debug("Loading internal static content.")
static = util.get_resources("content") static = util.get_resources("content")
for dir, resources in [ for dir, resources in [
@ -108,9 +147,13 @@ def initialize_site(root: Path | None = None):
logger.debug("Loading default configuation.") logger.debug("Loading default configuation.")
config = ZonaConfig() config = ZonaConfig()
logger.debug(f"Writing default configuration to {config_path}.") 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: with open(config_path, "w") as f:
yaml.dump( yaml.dump(
asdict(config), config_dict,
f, f,
sort_keys=False, sort_keys=False,
default_flow_style=False, default_flow_style=False,

View file

@ -1,5 +1,5 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date, datetime, time, tzinfo
from pathlib import Path from pathlib import Path
import frontmatter import frontmatter
@ -10,31 +10,50 @@ from dateutil import parser as date_parser
from yaml import YAMLError from yaml import YAMLError
import zona.util import zona.util
from zona.config import ZonaConfig
@dataclass @dataclass
class Metadata: class Metadata:
title: str title: str
date: date date: datetime
description: str | None description: str
show_title: bool = True show_title: bool = True
show_date: bool = True
show_nav: bool = True
style: str | None = "/static/style.css" style: str | None = "/static/style.css"
header: bool = True header: bool = True
footer: bool = True footer: bool = True
template: str | None = None template: str | None = None
post: bool | None = None post: bool | None = None
draft: bool = False draft: bool = False
ignore: bool = False
math: bool = True math: bool = True
def parse_date(raw_date: str | date | object) -> date: def ensure_timezone(dt: datetime, tz: tzinfo) -> datetime:
if isinstance(raw_date, date): if dt.tzinfo is None or dt.utcoffset() is None:
return raw_date dt = dt.replace(tzinfo=tz)
return dt
# TODO: migrate to using datetime, where user can optionall specify
# a time as well. if only date is given, default to time.min
def parse_date(
raw_date: str | datetime | date | object, tz: tzinfo
) -> datetime:
if isinstance(raw_date, datetime):
return ensure_timezone(raw_date, tz)
elif isinstance(raw_date, date):
return datetime.combine(raw_date, time.min, tzinfo=tz)
assert isinstance(raw_date, str) assert isinstance(raw_date, str)
return date_parser.parse(raw_date).date() dt = date_parser.parse(raw_date)
return ensure_timezone(dt, tz)
def parse_metadata(path: Path) -> tuple[Metadata, str]: def parse_metadata(
path: Path, config: ZonaConfig
) -> tuple[Metadata, str]:
""" """
Parses a file and returns parsed Metadata and its content. Defaults Parses a file and returns parsed Metadata and its content. Defaults
are applied for missing fields. If there is no metadata, a Metadata are applied for missing fields. If there is no metadata, a Metadata
@ -50,10 +69,11 @@ def parse_metadata(path: Path) -> tuple[Metadata, str]:
raw_meta = post.metadata or {} raw_meta = post.metadata or {}
defaults = { defaults = {
"title": zona.util.filename_to_title(path), "title": zona.util.filename_to_title(path),
"date": date.fromtimestamp(path.stat().st_ctime), "date": datetime.fromtimestamp(path.stat().st_ctime),
"description": config.blog.defaults.description,
} }
meta = {**defaults, **raw_meta} meta = {**defaults, **raw_meta}
meta["date"] = parse_date(meta.get("date")) meta["date"] = parse_date(meta.get("date"), config.feed.timezone)
try: try:
metadata = from_dict( metadata = from_dict(
data_class=Metadata, data_class=Metadata,

View file

@ -1,3 +1,5 @@
from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
@ -21,6 +23,8 @@ class Item:
type: ItemType | None = None type: ItemType | None = None
copy: bool = True copy: bool = True
post: bool = False post: bool = False
newer: Item | None = None
older: Item | None = None
# @dataclass # @dataclass

View file

@ -237,9 +237,13 @@ def serve(
observer.schedule( observer.schedule(
event_handler, path=str(root / "content"), recursive=True event_handler, path=str(root / "content"), recursive=True
) )
observer.schedule( templates = root / "templates"
event_handler, path=str(root / "templates"), recursive=True if templates.is_dir():
) observer.schedule(
event_handler,
path=str(templates),
recursive=True,
)
observer.start() observer.start()
# function to shut down gracefully # function to shut down gracefully

View file

@ -3,6 +3,7 @@ from typing import Literal
from jinja2 import Environment, FileSystemLoader, select_autoescape from jinja2 import Environment, FileSystemLoader, select_autoescape
from zona import util
from zona.config import ZonaConfig from zona.config import ZonaConfig
from zona.markdown import md_to_html from zona.markdown import md_to_html
from zona.models import Item from zona.models import Item
@ -26,7 +27,6 @@ def get_footer(template_dir: Path) -> str | None:
return html_footer.read_text() 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? # TODO: add a recent posts element that can be included elsewhere?
class Templater: class Templater:
def __init__( def __init__(
@ -35,6 +35,7 @@ class Templater:
template_dir: Path, template_dir: Path,
post_list: list[Item], post_list: list[Item],
): ):
# build temporary template dir
self.env: Environment = Environment( self.env: Environment = Environment(
loader=FileSystemLoader(template_dir), loader=FileSystemLoader(template_dir),
autoescape=select_autoescape(["html", "xml"]), autoescape=select_autoescape(["html", "xml"]),
@ -76,5 +77,12 @@ class Templater:
metadata=meta, metadata=meta,
header=header, header=header,
footer=footer, 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, post_list=self.post_list,
) )

View file

@ -1,11 +1,36 @@
import fnmatch import fnmatch
import re import re
import shutil
import string import string
import tempfile
import weakref
from importlib import resources from importlib import resources
from importlib.resources.abc import Traversable from importlib.resources.abc import Traversable
from pathlib import Path from pathlib import Path
from shutil import copy2 from shutil import copy2
from typing import NamedTuple from typing import Any, NamedTuple, override
class TempDir:
"""Temporary directory that cleans up when it's garbage collected."""
def __init__(self):
self._tempdir: str = tempfile.mkdtemp()
self.path: Path = Path(self._tempdir)
self._finalizer: weakref.finalize[Any, Any] = (
weakref.finalize(self, shutil.rmtree, self._tempdir)
)
def remove(self):
self._finalizer()
@property
def removed(self):
return not self._finalizer.alive
@override
def __repr__(self) -> str:
return f"<TempDir {self.path}>"
class ZonaResource(NamedTuple): class ZonaResource(NamedTuple):
@ -19,7 +44,9 @@ def get_resource(path: str) -> ZonaResource:
if file.is_file(): if file.is_file():
return ZonaResource(name=path, contents=file.read_text()) return ZonaResource(name=path, contents=file.read_text())
else: else:
raise FileNotFoundError(f"{path} is not a valid Zona resource!") raise FileNotFoundError(
f"{path} is not a valid Zona resource!"
)
def get_resources(subdir: str) -> list[ZonaResource]: def get_resources(subdir: str) -> list[ZonaResource]:
@ -63,6 +90,15 @@ def copy_static_file(src: Path, dst: Path):
copy2(src, dst) 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: def filename_to_title(path: Path) -> str:
name = path.stem name = path.stem
words = name.replace("-", " ").replace("_", " ") words = name.replace("-", " ").replace("_", " ")
@ -75,10 +111,13 @@ def normalize_url(url: str) -> str:
return url return url
def should_ignore(path: Path, patterns: list[str], base: Path) -> bool: def should_ignore(
path: Path, patterns: list[str], base: Path
) -> bool:
rel_path = path.relative_to(base) rel_path = path.relative_to(base)
return any( return any(
fnmatch.fnmatch(str(rel_path), pattern) for pattern in patterns fnmatch.fnmatch(str(rel_path), pattern)
for pattern in patterns
) )

54
uv.lock generated
View file

@ -44,6 +44,16 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/35/386550fd60316d1e37eccdda609b074113298f23cef5bddb2049823fe666/dacite-1.9.2-py3-none-any.whl", hash = "sha256:053f7c3f5128ca2e9aceb66892b1a3c8936d02c686e707bee96e19deef4bc4a0", size = 16600, upload-time = "2025-02-05T09:27:24.345Z" }, { 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]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.1.0" version = "2.1.0"
@ -87,6 +97,46 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/fd/aba08bb9e527168efad57985d7db9a853eb2384b1efa5ca5f3a3794c9cef/latex2mathml-3.78.0-py3-none-any.whl", hash = "sha256:1aeca3dc027b3006ad7b301b7f4a15ffbb4c1451e3dc8c3389e97b37b497e1d6", size = 73673, upload-time = "2025-05-03T16:51:51.991Z" }, { 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]] [[package]]
name = "markdown" name = "markdown"
version = "3.8.2" version = "3.8.2"
@ -459,10 +509,11 @@ wheels = [
[[package]] [[package]]
name = "zona" name = "zona"
version = "1.0.0" version = "1.2.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "dacite" }, { name = "dacite" },
{ name = "feedgen" },
{ name = "jinja2" }, { name = "jinja2" },
{ name = "l2m4m" }, { name = "l2m4m" },
{ name = "markdown" }, { name = "markdown" },
@ -489,6 +540,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "dacite", specifier = ">=1.9.2" }, { name = "dacite", specifier = ">=1.9.2" },
{ name = "feedgen", specifier = ">=1.0.0" },
{ name = "jinja2", specifier = ">=3.1.6" }, { name = "jinja2", specifier = ">=3.1.6" },
{ name = "l2m4m", specifier = ">=1.0.4" }, { name = "l2m4m", specifier = ">=1.0.4" },
{ name = "markdown", specifier = ">=3.8.2" }, { name = "markdown", specifier = ">=3.8.2" },