Compare commits
25 commits
21d4d3181e
...
70887156fb
Author | SHA1 | Date | |
---|---|---|---|
70887156fb | |||
1f2b736815 | |||
c489a6f076 | |||
933210c93b | |||
404e951651 | |||
24919171ad | |||
de86a92928 | |||
2bc12ff2ed | |||
07152a3746 | |||
27178dc6d8 | |||
04948a9373 | |||
faac8f63bb | |||
e28a206067 | |||
8b849fd2a0 | |||
e69dd80875 | |||
db8318622c | |||
0e32024147 | |||
a789d1f64e | |||
46934e502f | |||
0166e82cce | |||
10d1772a2d | |||
fe0f338803 | |||
1c64d1f431 | |||
585a987c3f | |||
dacea2756a |
22 changed files with 800 additions and 393 deletions
17
.forgejo/workflows/publish.yml
Normal file
17
.forgejo/workflows/publish.yml
Normal file
|
@ -0,0 +1,17 @@
|
|||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: based-alpine
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: build
|
||||
run: |
|
||||
uv sync
|
||||
uv build
|
||||
- name: publish
|
||||
run: |
|
||||
uv publish --token ${{ secrets.PYPI_TOKEN }}
|
||||
|
33
LICENSE
33
LICENSE
|
@ -1,13 +1,24 @@
|
|||
Copyright (c) 2025 Daniel Fichtinger <daniel@ficd.ca>
|
||||
Copyright (c) 2025 Daniel Fichtinger <daniel@ficd.sh>
|
||||
|
||||
Permission to use, copy, modify, and distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
3. Neither the name of the author nor the names of its contributors may
|
||||
be used to endorse or promote products derived from this software
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
|
||||
OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
|
||||
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||||
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
|
||||
OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGE.
|
||||
|
|
348
README.md
348
README.md
|
@ -1,27 +1,19 @@
|
|||
<h1>zona</h1>
|
||||
|
||||
[zona](https://sr.ht/~ficd/zona) is an _opinionated_ static site generator
|
||||
written in Python. From a structured directory of Markdown content, zona
|
||||
builds a simple static website. It's designed to get out of your way and
|
||||
let you focus on writing.
|
||||
[zona](https://git.ficd.sh/ficd/zona) is an _opinionated_ static site generator
|
||||
written in Python. From a structured directory of Markdown content, zona builds
|
||||
a simple static website. It's designed to get out of your way and let you focus
|
||||
on writing.
|
||||
|
||||
**What do I mean by opinionated?** I built zona primarily for myself. I've
|
||||
tried making it flexible by exposing as many variables as possible to the
|
||||
template engine. However, if you're looking for something stable,
|
||||
complete, and fully configurable, zona may not be for you. If you want a
|
||||
minimal Markdown blog and are comfortable with modifying `jinja2`
|
||||
templates and CSS, then you're in luck.
|
||||
|
||||
**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.
|
||||
**What do I mean by opinionated?** I built zona primarily for myself. I've tried
|
||||
making it flexible by exposing as many variables as possible to the template
|
||||
engine. However, if you're looking for something stable, complete, and fully
|
||||
configurable, zona may not be for you. If you want a minimal Markdown blog and
|
||||
are comfortable with modifying `jinja2` templates and CSS, then you're in luck.
|
||||
|
||||
For an example of a website built with zona, please see
|
||||
[ficd.sh](https://ficd.sh).
|
||||
[ficd.sh](https://ficd.sh). For a list of known problems, see
|
||||
[Known Problems](#known-problems).
|
||||
|
||||
<!--toc:start-->
|
||||
|
||||
|
@ -31,6 +23,7 @@ For an example of a website built with zona, please see
|
|||
- [Getting Started](#getting-started)
|
||||
- [Building](#building)
|
||||
- [Live Preview](#live-preview)
|
||||
- [Live Reload](#live-reload)
|
||||
- [How It Works](#how-it-works)
|
||||
- [Site Layout](#site-layout)
|
||||
- [Templates](#templates)
|
||||
|
@ -45,6 +38,7 @@ For an example of a website built with zona, please see
|
|||
- [Sitemap](#sitemap)
|
||||
- [Ignore List](#ignore-list)
|
||||
- [Drafts](#drafts)
|
||||
- [Known Problems](#known-problems)
|
||||
|
||||
<!--toc:end-->
|
||||
|
||||
|
@ -60,24 +54,29 @@ For an example of a website built with zona, please see
|
|||
- Easily configurable sitemap header.
|
||||
- Site footer written in Markdown.
|
||||
- Smart site layout discovery.
|
||||
- Blog posts are automatically discovered and rendered accordingly (can
|
||||
be overridden in frontmatter).
|
||||
- Blog posts are automatically discovered and rendered accordingly (can be
|
||||
overridden in frontmatter).
|
||||
- Extended Markdown renderer:
|
||||
- Smart internal link resolution.
|
||||
- Syntax highlighting.
|
||||
- Includes Kakoune syntax and [Ashen] highlighting.
|
||||
- [Image labels](#image-labels).
|
||||
- Many `python-markdown` extensions enabled, including footnotes,
|
||||
tables, abbreviations, etc.
|
||||
- Many `python-markdown` extensions enabled, including footnotes, tables,
|
||||
abbreviations, etc.
|
||||
- LaTeX support.
|
||||
|
||||
## Installation
|
||||
|
||||
zona is not yet packaged on PyPI. You may use `uv` to install it from this
|
||||
repository:
|
||||
Zona can be installed as a Python package. Instructions for
|
||||
[`uv`](https://docs.astral.sh/uv/) are provided.
|
||||
|
||||
```sh
|
||||
uv tool install 'git+https://git.sr.ht/~ficd/zona'
|
||||
# install latest release
|
||||
uv tool install zona
|
||||
# install bleeding edge from git
|
||||
uv tool install 'git+https://git.ficd.sh/ficd/zona'
|
||||
# you can also run without installation
|
||||
uvx zona build --help
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
@ -87,71 +86,78 @@ available options and arguments._
|
|||
|
||||
### Getting Started
|
||||
|
||||
To set up a new website, create a new directory and run `zona init` inside
|
||||
of it. This creates the required directory structure and writes the
|
||||
default configuration file. The default templates and default stylesheet
|
||||
are also written.
|
||||
To set up a new website, create a new directory and run `zona init` inside of
|
||||
it. This creates the required directory structure and writes the default
|
||||
configuration file. The default templates and default stylesheet are also
|
||||
written.
|
||||
|
||||
### Building
|
||||
|
||||
To build the website, run `zona build`. The project root is discovered
|
||||
according to the location of `config.yml`. By default, the output
|
||||
directory is called `public`, and saved inside the root directory.
|
||||
To build the website, run `zona build`. The project root is discovered according
|
||||
to the location of `config.yml`. By default, the output directory is called
|
||||
`public`, and saved inside the root directory.
|
||||
|
||||
If you don't want discovery, you can specify the project root as the first
|
||||
argument to `zona build`. You may specify a path for the output using the
|
||||
`--output/-o` flag. The `--draft/-d` flag includes draft posts in the
|
||||
output.
|
||||
`--output/-o` flag. The `--draft/-d` flag includes draft posts in the output.
|
||||
|
||||
_Note: the previous build is _not_ cleaned before the new site is built.
|
||||
If you've deleted some pages, you may need to remove the output directory
|
||||
before rebuilding._
|
||||
_Note: the previous build is _not_ cleaned before the new site is built. If
|
||||
you've deleted some pages, you may need to remove the output directory before
|
||||
rebuilding._
|
||||
|
||||
### Live Preview
|
||||
|
||||
To make the writing process as frictionless as possible, zona ships with a
|
||||
live preview server. It spins up an HTTP server, meaning that internal
|
||||
links work properly (this is not the case if you simply open the `.html`
|
||||
files in your browser.)
|
||||
To make the writing process as frictionless as possible, zona ships with a live
|
||||
preview server. It spins up an HTTP server, meaning that internal links work
|
||||
properly (this is not the case if you simply open the `.html` files in your
|
||||
browser.)
|
||||
|
||||
Additionally, the server watches for changes to all source files, and
|
||||
rebuilds the website when they're modified. _Note: the entire website is
|
||||
rebuilt — this ensures that links are properly resolved._
|
||||
Additionally, the server watches for changes to all source files, and rebuilds
|
||||
the website when they're modified. _Note: the entire website is rebuilt — this
|
||||
ensures that links are properly resolved._
|
||||
|
||||
Optionally, live reloading of the browser is also provided. With this
|
||||
feature (enabled by default), your browser will automatically refresh open
|
||||
pages whenever the site is rebuilt. The live reloading requires JavaScript
|
||||
support from the browser — this is why the feature is optional.
|
||||
Drafts are enabled by default in live preview. Use `--final/-f` to disable them.
|
||||
By default, the build outputs to a temporary directory. Use `-o/--output` to
|
||||
override this.
|
||||
|
||||
To start a preview server, use `zona serve`. You can specify the root
|
||||
directory as its first argument. Use the `--host` to specify a host name
|
||||
(`localhost` by default) and `--port/-p` to specify a port (default:
|
||||
`8000`). The `--no-live-reload/-n` disables the live browser reloading
|
||||
(_automatic site rebuilds are not disabled_).
|
||||
**Note**: if the live preview isn't working as expected, try restarting the
|
||||
server. If you change the configuration or any templates, the server must also
|
||||
be restarted. The live preview uses the same function as `zona build`
|
||||
internally; this means that the output is also written to disk.
|
||||
|
||||
Drafts are enabled by default in live preview. Use `--final/-f` to disable
|
||||
them. By default, the build outputs to a temporary directory. Use
|
||||
`-o/--output` to override this.
|
||||
#### Live Reload
|
||||
|
||||
**Note**: if the live preview isn't working as expected, try restarting
|
||||
the server. If you change the configuration or any templates, the server
|
||||
must also be restarted. The live preview uses the same function as
|
||||
`zona build` internally; this means that the output is also written to
|
||||
disk.
|
||||
Optionally, live reloading of the browser is also provided. With this feature
|
||||
(enabled by default), your browser will automatically refresh open pages
|
||||
whenever the site is rebuilt. The live reloading requires JavaScript support
|
||||
from the browser — this is why the feature is optional.
|
||||
|
||||
To start a preview server, use `zona serve`. You can specify the root directory
|
||||
as its first argument. Use the `--host` to specify a host name (`localhost` by
|
||||
default) and `--port/-p` to specify a port (default: `8000`).
|
||||
|
||||
The `--live-reload/--no-live-reload` option overrides the value set in the
|
||||
[config](#configuration) (`true` by default). _Automatic site rebuilds are not
|
||||
affected_.
|
||||
|
||||
If you are scrolled to the bottom of the page in the browser, and you extend the
|
||||
height of the page by adding new content, you will automatically be scrolled to
|
||||
the _new_ bottom after reloading. You may tune the tolerance threshold in the
|
||||
[configuration](#configuration).
|
||||
|
||||
#### How It Works
|
||||
|
||||
The basic idea is this: after a rebuild, the server needs to notify your
|
||||
browser to refresh the open pages. We implement this using a small amount
|
||||
of JavaScript. The server injects a tiny script into any HTML page it
|
||||
serves; which causes your browser to open a WebSocket connection with the
|
||||
server. When the site is rebuilt, the server notifies your browser via the
|
||||
WebSocket, which reloads the page.
|
||||
The basic idea is this: after a rebuild, the server needs to notify your browser
|
||||
to refresh the open pages. We implement this using a small amount of JavaScript.
|
||||
The server injects a tiny script into any HTML page it serves; which causes your
|
||||
browser to open a WebSocket connection with the server. When the site is
|
||||
rebuilt, the server notifies your browser via the WebSocket, which reloads the
|
||||
page.
|
||||
|
||||
Unfortunately, there is no way to implement this feature without using
|
||||
JavaScript. **JavaScript is _only_ used for the live preview feature. The
|
||||
script is injected by the server, and never written to the HTML files in
|
||||
the output directory.**
|
||||
JavaScript. **JavaScript is _only_ used for the live preview feature. The script
|
||||
is injected by the server, and never written to the HTML files in the output
|
||||
directory.**
|
||||
|
||||
### Site Layout
|
||||
|
||||
|
@ -164,32 +170,31 @@ templates/
|
|||
public/
|
||||
```
|
||||
|
||||
The **root** of the zona **project** _must_ contain the configuration
|
||||
file, `config.yml`, and a directory called `content`. A directory called
|
||||
`templates` is optional, and prioritized if it exists. `public` is the
|
||||
built site output — it's recommended to add this path to your
|
||||
`.gitignore`.
|
||||
The **root** of the zona **project** _must_ contain the configuration file,
|
||||
`config.yml`, and a directory called `content`. A directory called `templates`
|
||||
is optional, and merged with the defaults if it exists. `public` is the built
|
||||
site output — it's recommended to add this path to your `.gitignore`.
|
||||
|
||||
The `content` directory is the **root of the website**. Think of it as the
|
||||
**content root**. For example, suppose your website is hosted at
|
||||
`example.com`. `content/blog/index.md` corresponds to `example.com/blog`,
|
||||
**content root**. For example, suppose your website is hosted at `example.com`.
|
||||
`content/blog/index.md` corresponds to `example.com/blog`,
|
||||
`content/blog/my-post.md` becomes `example.com/blog/my-post`, etc.
|
||||
|
||||
- Internal links are resolved **relative to the `content` directory.**
|
||||
- Templates are resolved relative to the `template` directory.
|
||||
|
||||
Markdown files inside a certain directory (`content/blog` by default) are
|
||||
automatically treated as _blog posts_. This means they are rendered with
|
||||
the `page` template, and included in the `post_list`, which can be
|
||||
included in your site using the `post_list` template.
|
||||
automatically treated as _blog posts_. This means they are rendered with the
|
||||
`page` template, and included in the `post_list`, which can be included in your
|
||||
site using the `post_list` template.
|
||||
|
||||
### Templates
|
||||
|
||||
The `templates` directory may contain any `jinja2` template files. You may
|
||||
modify the existing templates or create your own. To apply a certain
|
||||
template to a page, set the `template` option in its
|
||||
[frontmatter](#frontmatter). The following public variables are made
|
||||
available to the template engine:
|
||||
modify the existing templates or create your own. Your templates are merged with
|
||||
the packaged defaults. To apply a certain template to a page, set the `template`
|
||||
option in its [frontmatter](#frontmatter). The following public variables are
|
||||
made available to the template engine:
|
||||
|
||||
| Name | Description |
|
||||
| ---------- | ------------------------------------------------------ |
|
||||
|
@ -201,43 +206,40 @@ available to the template engine:
|
|||
|
||||
#### Markdown Footer
|
||||
|
||||
The `templates` directory can contain a file called `footer.md`. If it
|
||||
exists, it's parsed and rendered into HTML, then made available to other
|
||||
templates as the `footer` variable. If `footer.md` is missing but
|
||||
`footer.html` exists, then it's used instead. **Note: links are _not_
|
||||
resolved in the footer.**
|
||||
The `templates` directory can contain a file called `footer.md`. If it exists,
|
||||
it's parsed and rendered into HTML, then made available to other templates as
|
||||
the `footer` variable. If `footer.md` is missing but `footer.html` exists, then
|
||||
it's used instead. **Note: links are _not_ resolved in the footer.**
|
||||
|
||||
### Internal Link Resolution
|
||||
|
||||
When zona encounters links in Markdown documents, it attempts to resolve
|
||||
them as internal links. Links beginning with `/` are resolved relative to
|
||||
the content root; otherwise, they are resolved relative to the Markdown
|
||||
file. If the link resolves to an existing file that is part of the
|
||||
website, it's replaced with an appropriate web-server-friendly link.
|
||||
Otherwise, the link isn't changed.
|
||||
When zona encounters links in Markdown documents, it attempts to resolve them as
|
||||
internal links. Links beginning with `/` are resolved relative to the content
|
||||
root; otherwise, they are resolved relative to the Markdown file. If the link
|
||||
resolves to an existing file that is part of the website, it's replaced with an
|
||||
appropriate web-server-friendly link. Otherwise, the link isn't changed.
|
||||
|
||||
For example, suppose the file `blog/post1.md` has a link `./post2.md`. The
|
||||
HTML output will contain the link `/blog/post2` (which corresponds to
|
||||
`/blog/post2/index.html`). Link resolution is applied to _all_ internal
|
||||
links, including those pointing to static resources like images. Links are
|
||||
only modified if they point to a real file that's not included in the
|
||||
ignore list.
|
||||
For example, suppose the file `blog/post1.md` has a link `./post2.md`. The HTML
|
||||
output will contain the link `/blog/post2` (which corresponds to
|
||||
`/blog/post2/index.html`). Link resolution is applied to _all_ internal links,
|
||||
including those pointing to static resources like images. Links are only
|
||||
modified if they point to a real file that's not included in the ignore list.
|
||||
|
||||
### Syntax Highlighting
|
||||
|
||||
Zona uses [Pygments] to provide syntax highlighting for fenced code
|
||||
blocks. The following Pygments plugins are included:
|
||||
Zona uses [Pygments] to provide syntax highlighting for fenced code blocks. The
|
||||
following Pygments plugins are included:
|
||||
|
||||
- [pygments-kakoune](https://git.sr.ht/~ficd/pygments-kakoune)
|
||||
- A lexer providing for highlighting Kakoune code. Available under the
|
||||
`kak` and `kakrc` aliases.
|
||||
- [pygments-ashen](https://git.sr.ht/~ficd/ashen/tree/main/item/pygments/README.md)
|
||||
- An implementation of the [Ashen](https://git.sr.ht/~ficd/ashen) theme
|
||||
for Pygments.
|
||||
- [pygments-kakoune](https://codeberg.com/ficd/pygments-kakoune)
|
||||
- A lexer providing for highlighting Kakoune code. Available under the `kak`
|
||||
and `kakrc` aliases.
|
||||
- [pygments-ashen](https://codeberg.com/ficd/ashen/tree/main/item/pygments/README.md)
|
||||
- An implementation of the [Ashen](https://codeberg.com/ficd/ashen) theme for
|
||||
Pygments.
|
||||
|
||||
If you want to use any external Pygments styles or lexers, they must be
|
||||
available in zona's Python environment. For example, you can give zona
|
||||
access to [Catppucin](https://github.com/catppuccin/python):
|
||||
available in zona's Python environment. For example, you can give zona access to
|
||||
[Catppucin](https://github.com/catppuccin/python):
|
||||
|
||||
```yaml
|
||||
# config.yml
|
||||
|
@ -252,9 +254,9 @@ Then, run zona with the following `uv` command:
|
|||
uvx --with catppucin zona build
|
||||
```
|
||||
|
||||
Inline syntax highlighting is also provided via a `python-markdown`
|
||||
extension. If you prefix inline code with a shebang followed by the
|
||||
language identifier, it will be highlighted. For example:
|
||||
Inline syntax highlighting is also provided via a `python-markdown` extension.
|
||||
If you prefix inline code with a shebang followed by the language identifier, it
|
||||
will be highlighted. For example:
|
||||
|
||||
```
|
||||
`#!python print(f"I love {foobar}!", end="")`
|
||||
|
@ -280,10 +282,9 @@ will be rendered as
|
|||
|
||||
### Image Labels
|
||||
|
||||
A feature unique to zona is **image labels**. They make it easy to
|
||||
annotate images in your Markdown documents. The alt text Markdown element
|
||||
is rendered as the label — with support for inline Markdown. Consider this
|
||||
example:
|
||||
A feature unique to zona is **image labels**. They make it easy to annotate
|
||||
images in your Markdown documents. The alt text Markdown element is rendered as
|
||||
the label — with support for inline Markdown. Consider this example:
|
||||
|
||||
```markdown
|
||||

|
||||
|
@ -298,14 +299,14 @@ The above results in the following HTML:
|
|||
```
|
||||
|
||||
The `image-container` class is provided as a convenience for styling. The
|
||||
default stylesheet centers the label under the image. Note: _links_ inside
|
||||
image captions are not currently supported. I am looking into a solution.
|
||||
default stylesheet centers the label under the image. Note: _links_ inside image
|
||||
captions are not currently supported. I am looking into a solution.
|
||||
|
||||
### Frontmatter
|
||||
|
||||
YAML frontmatter can be used to configure the metadata of documents. All
|
||||
of them are optional. `none` is used when the option is unset. The
|
||||
following options are available:
|
||||
YAML frontmatter can be used to configure the metadata of documents. All of them
|
||||
are optional. `none` is used when the option is unset. The following options are
|
||||
available:
|
||||
|
||||
| Key | Type & Default | Description |
|
||||
| ------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
|
@ -317,6 +318,7 @@ following options are available:
|
|||
| `template` | `str \| none` = `none` | Template to use for this page. Relative to `templates/`, `.html` extension optional. |
|
||||
| `post` | `bool \| none` = `none` | Whether this page is a **post**. `true`/`false` is _absolute_. Leave it unset for automatic detection. |
|
||||
| `draft` | `bool` = `false` | Whether this page is a draft. See [drafts](#drafts) for more. |
|
||||
| `ignore` | `bool` = `false` | Whether this page should be ignored in _both_ `final` and `draft` contexts. |
|
||||
| `math` | `bool` = `true` | Whether the LaTeX extension should be enabled for this page. |
|
||||
|
||||
**Note**: you can specify the date in any format that can be parsed by
|
||||
|
@ -338,10 +340,9 @@ template: post_list
|
|||
Welcome to my blog! Please find a list of my posts below.
|
||||
```
|
||||
|
||||
Setting `post: false` is necessary because, by default, all documents
|
||||
inside `content/blog` are considered to be posts unless explicitly
|
||||
disabled in the frontmatter. We don't want the post list to list _itself_
|
||||
as a post.
|
||||
Setting `post: false` is necessary because, by default, all documents inside
|
||||
`content/blog` are considered to be posts unless explicitly disabled in the
|
||||
frontmatter. We don't want the post list to list _itself_ as a post.
|
||||
|
||||
Then, you'd create `content/blog/my-post.md` and populate it:
|
||||
|
||||
|
@ -352,32 +353,32 @@ date: July 5, 2025
|
|||
---
|
||||
```
|
||||
|
||||
Because `my-post` is inside the `blog` directory, `post: true` is implied.
|
||||
If you wanted to put it somewhere outside `blog`, you would need to set
|
||||
Because `my-post` is inside the `blog` directory, `post: true` is implied. If
|
||||
you wanted to put it somewhere outside `blog`, you would need to set
|
||||
`post: true` for it to be included in the post list.
|
||||
|
||||
## Configuration
|
||||
|
||||
Zona is configured in YAML format. The configuration file is called
|
||||
`config.yml` and it **must** be located in the root of the project — in
|
||||
the same directory as `content` and `templates`.
|
||||
Zona is configured in YAML format. The configuration file is called `config.yml`
|
||||
and it **must** be located in the root of the project — in the same directory as
|
||||
`content` and `templates`.
|
||||
|
||||
Your configuration will be merged with the defaults. `zona init` also
|
||||
writes a copy of the default configuration to the correct location. If it
|
||||
exists, you'll be prompted before overwriting it.
|
||||
Your configuration will be merged with the defaults. `zona init` also writes a
|
||||
copy of the default configuration to the correct location. If it exists, you'll
|
||||
be prompted before overwriting it.
|
||||
|
||||
**Note:** Currently, not every configuration value is actually used. Only
|
||||
the useful settings are listed here.
|
||||
**Note:** Currently, not every configuration value is actually used. Only the
|
||||
useful settings are listed here.
|
||||
|
||||
Please see the default configuration:
|
||||
|
||||
```yaml
|
||||
base_url: /
|
||||
sitemap:
|
||||
Home: /
|
||||
ignore:
|
||||
- .marksman.toml
|
||||
markdown:
|
||||
image_labels: true
|
||||
tab_length: 2
|
||||
syntax_highlighting:
|
||||
enabled: true
|
||||
|
@ -385,49 +386,70 @@ markdown:
|
|||
wrap: false
|
||||
links:
|
||||
external_new_tab: true
|
||||
build:
|
||||
clean_output_dir: true
|
||||
include_drafts: false
|
||||
blog:
|
||||
dir: blog
|
||||
server:
|
||||
reload:
|
||||
enabled: true
|
||||
scroll_tolerance: 100
|
||||
```
|
||||
|
||||
| Name | Description |
|
||||
| -------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| `sitemap` | Sitemap dictionary. See [Sitemap](#sitemap). |
|
||||
| `ignore` | List of paths to ignore. See [Ignore List](#ignore-list). |
|
||||
| `markdown.tab_length` | How many spaces should be considered an indentation level. |
|
||||
| `markdown.syntax_highlighting.enabled` | Whether code should be highlighted. |
|
||||
| `markdown.syntax_highlighting.theme` | [Pygments] style for highlighting. |
|
||||
| `markdown.syntax_highlighting.wrap` | Whether the resulting code block should be word wrapped. |
|
||||
| `markdown.links.external_new_tab` | Whether external links should be opened in a new tab. |
|
||||
| `blog.dir` | Name of a directory relative to `content/` whose children are automatically considered posts. |
|
||||
| Name | Description |
|
||||
| -------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `sitemap` | Sitemap dictionary. See [Sitemap](#sitemap). |
|
||||
| `ignore` | List of paths to ignore. See [Ignore List](#ignore-list). |
|
||||
| `markdown.tab_length` | How many spaces should be considered an indentation level. |
|
||||
| `markdown.syntax_highlighting.enabled` | Whether code should be highlighted. |
|
||||
| `markdown.syntax_highlighting.theme` | [Pygments] style for highlighting. |
|
||||
| `markdown.syntax_highlighting.wrap` | Whether the resulting code block should be word wrapped. |
|
||||
| `markdown.links.external_new_tab` | Whether external links should be opened in a new tab. |
|
||||
| `build.clean_output_dir` | Whether previous build artifacts should be cleared when building. Recommended to leave this on. |
|
||||
| `build.include_drafts` | Whether drafts should be included by default. |
|
||||
| `blog.dir` | Name of a directory relative to `content/` whose children are automatically considered posts. |
|
||||
| `server.reload.enabled` | Whether the preview server should use [live reload](#live-preview). |
|
||||
| `server.reload.scroll_tolerance` | The distance, in pixels, from the bottom to still count as "scrolled to bottom". |
|
||||
|
||||
### Sitemap
|
||||
|
||||
You can define a sitemap in the configuration file. This is a list of
|
||||
links that will be rendered at the top of every page. The `sitemap` is a
|
||||
dictionary of `string` to `string` pairs, where each key is the displayed
|
||||
text of the link, and the value if the `href`. Consider this example:
|
||||
You can define a sitemap in the configuration file. This is a list of links that
|
||||
will be rendered at the top of every page. The `sitemap` is a dictionary of
|
||||
`string` to `string` pairs, where each key is the displayed text of the link,
|
||||
and the value if the `href`. Consider this example:
|
||||
|
||||
```yaml
|
||||
sitemap:
|
||||
Home: /
|
||||
About: /about
|
||||
Blog: /blog
|
||||
Git: https://git.sr.ht/~ficd
|
||||
Git: https://git.ficd.sh/ficd
|
||||
```
|
||||
|
||||
### Ignore List
|
||||
|
||||
You can set a list of glob patterns in the [configuration](#configuration)
|
||||
that should be ignored by zona. This is useful because zona makes a copy
|
||||
of _every_ file it encounters inside the `content` directory, regardless
|
||||
of its type. The paths must be relative to the `content` directory.
|
||||
You can set a list of glob patterns in the [configuration](#configuration) that
|
||||
should be ignored by zona. This is useful because zona makes a copy of _every_
|
||||
file it encounters inside the `content` directory, regardless of its type. The
|
||||
paths must be relative to the `content` directory.
|
||||
|
||||
### Drafts
|
||||
|
||||
zona allows you to begin writing content without including it in the final
|
||||
build output. If you set `draft: true` in a page's frontmatter, it will be
|
||||
marked as a draft. Drafts are completely excluded from `zona build` and
|
||||
`zona serve` unless the `--draft` flag is specified.
|
||||
zona allows you to begin writing content without including it in the final build
|
||||
output. If you set `draft: true` in a page's frontmatter, it will be marked as a
|
||||
draft. Drafts are completely excluded from `zona build` and `zona serve` unless
|
||||
the `--draft` flag is specified.
|
||||
|
||||
[Ashen]: https://sr.ht/~ficd/ashen
|
||||
[Ashen]: https://codeberg.com/ficd/ashen
|
||||
[Pygments]: https://pygments.org/
|
||||
|
||||
## 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.
|
||||
|
|
22
justfile
22
justfile
|
@ -2,6 +2,28 @@
|
|||
default:
|
||||
@just --list
|
||||
|
||||
clean:
|
||||
#!/bin/sh
|
||||
if [ ! -d "dist" ] && [ ! -d "__pycache__" ]; then
|
||||
echo "Nothing to clean."
|
||||
exit 0
|
||||
fi
|
||||
if [ -d "dist" ]; then
|
||||
echo "Removing dist/"
|
||||
rm -r dist/
|
||||
fi
|
||||
if [ -d "__pycache__" ]; then
|
||||
echo "Removing __pycache__/"
|
||||
rm -r "__pycache__"
|
||||
fi
|
||||
|
||||
publish:
|
||||
#!/bin/sh
|
||||
just clean
|
||||
uv build
|
||||
export UV_PUBLISH_TOKEN="$(pass show pypi)"
|
||||
uv publish
|
||||
|
||||
format:
|
||||
uv run ruff format
|
||||
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
[project]
|
||||
name = "zona"
|
||||
version = "0.1.0"
|
||||
description = "Static site generator"
|
||||
version = "1.0.0"
|
||||
description = "Opinionated static site generator."
|
||||
license = "BSD-3-Clause "
|
||||
license-files = ["LICENSE"]
|
||||
readme = "README.md"
|
||||
authors = [
|
||||
{ name = "Daniel Fichtinger", email = "daniel@ficd.ca" },
|
||||
{ name = "Daniel Fichtinger", email = "daniel@ficd.sh" },
|
||||
]
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
@ -25,6 +27,9 @@ dependencies = [
|
|||
"websockets>=15.0.1",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://git.ficd.sh/ficd/zona"
|
||||
|
||||
[project.scripts]
|
||||
zona = "zona.cli:main"
|
||||
|
||||
|
@ -58,7 +63,7 @@ allowedUntypedLibraries = ["frontmatter", "pygments", "pymdownx", "l2m4m", "feed
|
|||
[tool.ruff]
|
||||
line-length = 70
|
||||
indent-width = 4
|
||||
target-version = "py311"
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
fixable = ["ALL"]
|
||||
|
|
|
@ -30,6 +30,7 @@ class ZonaBuilder:
|
|||
self.config.build.include_drafts = True
|
||||
self.items: list[Item] = []
|
||||
self.item_map: dict[Path, Item] = {}
|
||||
self.fresh: bool = True
|
||||
|
||||
def _discover(self):
|
||||
layout = self.layout
|
||||
|
@ -48,12 +49,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)
|
||||
if (
|
||||
if item.metadata.ignore or (
|
||||
item.metadata.draft
|
||||
and not self.config.build.include_drafts
|
||||
):
|
||||
|
@ -69,11 +72,13 @@ class ZonaBuilder:
|
|||
item.copy = False
|
||||
name = destination.stem
|
||||
if name == "index":
|
||||
item.destination = item.destination.with_suffix(
|
||||
".html"
|
||||
item.destination = (
|
||||
item.destination.with_suffix(".html")
|
||||
)
|
||||
else:
|
||||
relative = path.relative_to(base).with_suffix("")
|
||||
relative = path.relative_to(base).with_suffix(
|
||||
""
|
||||
)
|
||||
name = relative.stem
|
||||
item.destination = (
|
||||
layout.output
|
||||
|
@ -85,7 +90,9 @@ class ZonaBuilder:
|
|||
layout.output
|
||||
)
|
||||
item.url = (
|
||||
"" if rel_url == Path(".") else rel_url.as_posix()
|
||||
""
|
||||
if rel_url == Path(".")
|
||||
else rel_url.as_posix()
|
||||
)
|
||||
items.append(item)
|
||||
self.items = items
|
||||
|
@ -99,6 +106,13 @@ class ZonaBuilder:
|
|||
else date.min,
|
||||
reverse=True,
|
||||
)
|
||||
posts = len(post_list)
|
||||
for i, item in enumerate(post_list):
|
||||
prev = post_list[i - 1] if i > 0 else None
|
||||
next = post_list[i + 1] if i < posts - 2 else None
|
||||
item.previous = prev
|
||||
item.next = next
|
||||
|
||||
templater = Templater(
|
||||
config=self.config,
|
||||
template_dir=self.layout.templates,
|
||||
|
@ -111,7 +125,9 @@ class ZonaBuilder:
|
|||
# write code highlighting stylesheet
|
||||
if self.config.markdown.syntax_highlighting.enabled:
|
||||
pygments_style = zmd.get_style_defs(self.config)
|
||||
pygments_path = self.layout.output / "static" / "pygments.css"
|
||||
pygments_path = (
|
||||
self.layout.output / "static" / "pygments.css"
|
||||
)
|
||||
util.ensure_parents(pygments_path)
|
||||
pygments_path.write_text(pygments_style)
|
||||
for item in self.item_map.values():
|
||||
|
@ -145,8 +161,17 @@ class ZonaBuilder:
|
|||
and self.layout.output.is_dir()
|
||||
):
|
||||
logger.debug("Removing stale output...")
|
||||
shutil.rmtree(self.layout.output)
|
||||
# only remove output dir's children
|
||||
# to avoid breaking live preview
|
||||
for child in self.layout.output.iterdir():
|
||||
if child.is_file() or child.is_symlink():
|
||||
child.unlink()
|
||||
elif child.is_dir():
|
||||
shutil.rmtree(child)
|
||||
if not self.fresh:
|
||||
self.layout = self.layout.refresh()
|
||||
logger.debug("Discovering...")
|
||||
self._discover()
|
||||
logger.debug("Building...")
|
||||
self._build()
|
||||
self.fresh = False
|
||||
|
|
|
@ -59,7 +59,9 @@ def build(
|
|||
"""
|
||||
if draft:
|
||||
print("Option override: including drafts.")
|
||||
builder = ZonaBuilder(cli_root=root, cli_output=output, draft=draft)
|
||||
builder = ZonaBuilder(
|
||||
cli_root=root, cli_output=output, draft=draft
|
||||
)
|
||||
builder.build()
|
||||
|
||||
|
||||
|
@ -73,7 +75,9 @@ def serve(
|
|||
] = None,
|
||||
host: Annotated[
|
||||
str,
|
||||
typer.Option("--host", help="Hostname for live preview server."),
|
||||
typer.Option(
|
||||
"--host", help="Hostname for live preview server."
|
||||
),
|
||||
] = "localhost",
|
||||
port: Annotated[
|
||||
int,
|
||||
|
@ -95,14 +99,15 @@ def serve(
|
|||
bool,
|
||||
typer.Option("--final", "-f", help="Don't include drafts."),
|
||||
] = False,
|
||||
no_live_reload: Annotated[
|
||||
bool,
|
||||
live_reload: Annotated[
|
||||
bool | None,
|
||||
typer.Option(
|
||||
"--no-live-reload",
|
||||
"-n",
|
||||
help="Don't automatically reload web preview.",
|
||||
"--live-reload/--no-live-reload",
|
||||
"-l/-L",
|
||||
help="Automatically reload web preview. Overrides config.",
|
||||
show_default=False,
|
||||
),
|
||||
] = False,
|
||||
] = None,
|
||||
):
|
||||
"""
|
||||
Build the website and start a live preview server.
|
||||
|
@ -115,13 +120,17 @@ def serve(
|
|||
print("Preview without drafts.")
|
||||
else:
|
||||
print("Preview with drafts.")
|
||||
if live_reload is None:
|
||||
reload = None
|
||||
else:
|
||||
reload = live_reload
|
||||
server.serve(
|
||||
root=root,
|
||||
output=output,
|
||||
draft=not final,
|
||||
host=host,
|
||||
port=port,
|
||||
live_reload=not no_live_reload,
|
||||
user_reload=reload,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -58,6 +58,17 @@ class BuildConfig:
|
|||
include_drafts: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReloadConfig:
|
||||
enabled: bool = True
|
||||
scroll_tolerance: int = 100
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServerConfig:
|
||||
reload: ReloadConfig = field(default_factory=ReloadConfig)
|
||||
|
||||
|
||||
IGNORELIST = [".marksman.toml"]
|
||||
|
||||
|
||||
|
@ -69,12 +80,15 @@ class ZonaConfig:
|
|||
description: str = "My zona website."
|
||||
language: str = "en"
|
||||
# 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":
|
||||
|
|
|
@ -1,20 +1,53 @@
|
|||
:root {
|
||||
--main-text-color: #b4b4b4;
|
||||
--main-bg-color: #121212;
|
||||
--main-link-color: #df6464;
|
||||
--main-heading-color: #df6464;
|
||||
--main-bullet-color: #d87c4a;
|
||||
--main-transparent: rgba(255, 255, 255, 0.15);
|
||||
--main-small-text-color: rgba(255, 255, 255, 0.45);
|
||||
--main-text-color: #b4b4b4;
|
||||
--main-text-opaque-color: rgba(180, 180, 180, 0.8);
|
||||
--main-bg-color: #121212;
|
||||
--main-link-color: #df6464;
|
||||
--main-heading-color: #df6464;
|
||||
--main-bullet-color: #d87c4a;
|
||||
--orange-rgb: rgba(216, 124, 74, 0.6);
|
||||
--main-transparent: rgba(255, 255, 255, 0.15);
|
||||
--main-small-text-color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
line-height: 1.6;
|
||||
font-size: 18px;
|
||||
font-family: sans-serif;
|
||||
background: var(--main-bg-color);
|
||||
color: var(--main-text-color);
|
||||
padding-left: calc(100vw - 100%);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
font-size: 18px;
|
||||
font-family: sans-serif;
|
||||
background: var(--main-bg-color);
|
||||
color: var(--main-text-color);
|
||||
padding-left: calc(100vw - 100%);
|
||||
}
|
||||
|
||||
header {
|
||||
padding-top: -1rem;
|
||||
margin-top: -1rem;
|
||||
font-family: monospace;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.post-nav {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.post-nav .symbol {
|
||||
color: var(--main-bullet-color);
|
||||
}
|
||||
|
||||
.post-nav a {
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.site-logo {
|
||||
color: inherit;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
/* font-size: 1.75rem;*/
|
||||
}
|
||||
|
||||
.toclink {
|
||||
|
@ -63,278 +96,351 @@ h3,
|
|||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
color: var(--main-heading-color);
|
||||
color: var(--main-heading-color);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-block-start: 0.67rem;
|
||||
margin-block-end: 0.67rem;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
margin-block-start: 0.67rem;
|
||||
margin-block-end: 0.67rem;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-transform: lowercase;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
article h1:first-of-type {
|
||||
margin-block-start: 1.67rem;
|
||||
margin-block-start: 1.67rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-block-start: 0.83rem;
|
||||
margin-block-end: 0.83rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
margin-block-start: 0.83rem;
|
||||
margin-block-end: 0.83rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-block-start: 1rem;
|
||||
margin-block-end: 1rem;
|
||||
font-size: 1.17em;
|
||||
font-weight: bold;
|
||||
margin-block-start: 1rem;
|
||||
margin-block-end: 1rem;
|
||||
font-size: 1.17em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-block-start: 1.33rem;
|
||||
margin-block-end: 1.33rem;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
margin-block-start: 1.33rem;
|
||||
margin-block-end: 1.33rem;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
article h1+h4:first-of-type {
|
||||
margin-block-start: 0rem;
|
||||
article h1 + h4:first-of-type {
|
||||
margin-block-start: 0rem;
|
||||
}
|
||||
|
||||
h5 {
|
||||
margin-block-start: 1.67rem;
|
||||
margin-block-end: 1.67rem;
|
||||
font-size: 0.83rem;
|
||||
font-weight: bold;
|
||||
margin-block-start: 1.67rem;
|
||||
margin-block-end: 1.67rem;
|
||||
font-size: 0.83rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
h6 {
|
||||
margin-block-start: 2.33rem;
|
||||
margin-block-end: 2.33rem;
|
||||
font-size: 0.67rem;
|
||||
font-weight: bold;
|
||||
margin-block-start: 2.33rem;
|
||||
margin-block-end: 2.33rem;
|
||||
font-size: 0.67rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
/* or any other list style */
|
||||
list-style-type: disc;
|
||||
/* or any other list style */
|
||||
}
|
||||
|
||||
li::marker {
|
||||
color: var(--main-bullet-color);
|
||||
/* Change this to your desired color */
|
||||
color: var(--main-bullet-color);
|
||||
/* Change this to your desired color */
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--main-link-color);
|
||||
color: var(--main-link-color);
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
background: var(--main-transparent);
|
||||
a::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: -2px;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: currentColor;
|
||||
transform: scaleX(0);
|
||||
transform-origin: center;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
a:hover::after {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
a:has(> code)::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
img {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: auto;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
color: var(--main-small-text-color);
|
||||
border-left: 3px solid var(--main-transparent);
|
||||
padding: 0 1rem;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
color: var(--main-text-opaque-color);
|
||||
border-left: 3px solid var(--orange-rgb);
|
||||
padding: 0 1rem;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
height: 1px;
|
||||
background: var(--main-small-text-color);
|
||||
border: none;
|
||||
height: 1px;
|
||||
background: var(--main-small-text-color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
time {
|
||||
color: var(--main-bullet-color);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.post-list-date {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--main-transparent);
|
||||
border-radius: 0.1875rem;
|
||||
/* padding: .0625rem .1875rem; */
|
||||
/* margin: 0 .1875rem; */
|
||||
background: var(--main-transparent);
|
||||
border-radius: 0.1875rem;
|
||||
/* padding: .0625rem .1875rem; */
|
||||
/* margin: 0 .1875rem; */
|
||||
}
|
||||
|
||||
code,
|
||||
pre {
|
||||
white-space: pre;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
font-family: monospace;
|
||||
font-size: 0.95em;
|
||||
white-space: pre;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
font-family:
|
||||
ui-monospace,
|
||||
SFMono-Regular,
|
||||
SF Mono,
|
||||
Menlo,
|
||||
Consolas,
|
||||
Liberation Mono,
|
||||
monospace;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: #151515;
|
||||
color: #d5d5d5;
|
||||
padding: 1em;
|
||||
border-radius: 5px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
background-color: #1d1d1d;
|
||||
color: #d5d5d5;
|
||||
padding: 1em;
|
||||
border-radius: 5px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Inline code styling */
|
||||
:not(pre) > code {
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 3px;
|
||||
background-color: #1d1d1d;
|
||||
color: #d5d5d5;
|
||||
font-size: 0.85em;
|
||||
padding: 0.2em 0.4em;
|
||||
font-size: 0.85em;
|
||||
line-height: 1;
|
||||
background-color: #1d1d1d;
|
||||
border-radius: 6px;
|
||||
vertical-align: middle;
|
||||
font-family:
|
||||
ui-monospace,
|
||||
SFMono-Regular,
|
||||
SF Mono,
|
||||
Menlo,
|
||||
Consolas,
|
||||
Liberation Mono,
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* Block code styling (inherits from pre) */
|
||||
pre code {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 0.95rem;
|
||||
color: var(--main-small-text-color);
|
||||
font-size: 0.95rem;
|
||||
color: var(--main-small-text-color);
|
||||
}
|
||||
|
||||
small a {
|
||||
color: inherit;
|
||||
/* Inherit the color of the surrounding <small> text */
|
||||
text-decoration: underline;
|
||||
/* Optional: Keep the underline to indicate a link */
|
||||
color: inherit;
|
||||
/* Inherit the color of the surrounding <small> text */
|
||||
text-decoration: underline;
|
||||
/* Optional: Keep the underline to indicate a link */
|
||||
}
|
||||
|
||||
.title-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title-container h1 {
|
||||
margin: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.image-container {
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
/* Optional: add some spacing around the image container */
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
/* Optional: add some spacing around the image container */
|
||||
}
|
||||
|
||||
.image-container img {
|
||||
max-width: 100%;
|
||||
width: auto;
|
||||
max-height: 100%;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
width: auto;
|
||||
max-height: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.fixed .image-container img {
|
||||
max-width: 308px;
|
||||
max-height: 308px;
|
||||
max-width: 308px;
|
||||
max-height: 308px;
|
||||
}
|
||||
|
||||
.image-container small {
|
||||
display: block;
|
||||
/* Ensure the caption is on a new line */
|
||||
margin-top: 5px;
|
||||
/* Optional: adjust spacing between image and caption */
|
||||
display: block;
|
||||
/* Ensure the caption is on a new line */
|
||||
margin-top: 5px;
|
||||
/* Optional: adjust spacing between image and caption */
|
||||
}
|
||||
|
||||
.image-container small a {
|
||||
color: inherit;
|
||||
/* Ensure the link color matches the small text */
|
||||
text-decoration: underline;
|
||||
/* Optional: underline to indicate a link */
|
||||
color: inherit;
|
||||
/* Ensure the link color matches the small text */
|
||||
text-decoration: underline;
|
||||
/* Optional: underline to indicate a link */
|
||||
}
|
||||
|
||||
#header ul {
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
#header li {
|
||||
display: inline;
|
||||
font-size: 1.2rem;
|
||||
margin-right: 1.2rem;
|
||||
display: inline;
|
||||
font-size: 1.2rem;
|
||||
margin-right: 1.2rem;
|
||||
}
|
||||
|
||||
#container {
|
||||
margin: 2.5rem auto;
|
||||
width: 90%;
|
||||
max-width: 60ch;
|
||||
margin: 2.5rem auto;
|
||||
width: 90%;
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
#postlistdiv ul {
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.moreposts {
|
||||
font-size: 0.95rem;
|
||||
padding-left: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
#nextprev {
|
||||
text-align: center;
|
||||
margin-top: 1.4rem;
|
||||
font-size: 0.95rem;
|
||||
text-align: center;
|
||||
margin-top: 1.4rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
#footer {
|
||||
color: var(--main-small-text-color);
|
||||
color: var(--main-small-text-color);
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
margin: 1.5rem auto;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
font-size: 0.85rem;
|
||||
text-align: left; /* Use center if you prefer */
|
||||
border-collapse: collapse;
|
||||
margin: 1.5rem auto;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
font-size: 0.85rem;
|
||||
text-align: left; /* Use center if you prefer */
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid var(--main-transparent);
|
||||
/*border: 1px solid var(--main-bullet-color);*/
|
||||
padding: 0.4rem 0.8rem;
|
||||
vertical-align: middle;
|
||||
border: 1px solid var(--main-transparent);
|
||||
/*border: 1px solid var(--main-bullet-color);*/
|
||||
padding: 0.4rem 0.8rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
thead th {
|
||||
font-weight: bold;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
color: var(--main-text-color);
|
||||
font-weight: bold;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
tbody tr:nth-child(even) {
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
table code {
|
||||
font-family: monospace;
|
||||
font-size: 0.85em;
|
||||
background: #1d1d1d;
|
||||
padding: 0.1em 0.25em;
|
||||
border-radius: 3px;
|
||||
font-family:
|
||||
ui-monospace,
|
||||
SFMono-Regular,
|
||||
SF Mono,
|
||||
Menlo,
|
||||
Consolas,
|
||||
Liberation Mono,
|
||||
monospace;
|
||||
font-size: 0.85em;
|
||||
background: #1d1d1d;
|
||||
padding: 0.1em 0.25em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
caption {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--main-small-text-color);
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--main-small-text-color);
|
||||
}
|
||||
|
||||
a > code {
|
||||
text-decoration: none;
|
||||
color: var(--main-link-color);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
a:has(> code) {
|
||||
text-decoration: none;
|
||||
background: none;
|
||||
/* position: static;*/
|
||||
}
|
||||
|
||||
a:hover > code {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:hover:has(> code) {
|
||||
background: none;
|
||||
}
|
||||
|
|
25
src/zona/data/server/inject.js
Normal file
25
src/zona/data/server/inject.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
(() => {
|
||||
// if user at the bottom before reload, scroll to new bottom
|
||||
if (localStorage.getItem("wasAtBottom") === "1") {
|
||||
localStorage.removeItem("wasAtBottom");
|
||||
window.addEventListener("load", () => {
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const ws = new WebSocket("__SOCKET_ADDRESS__");
|
||||
const tol = __SCROLL_TOLERANCE__;
|
||||
ws.onmessage = event => {
|
||||
if (event.data === "reload") {
|
||||
// store flag if user currently at bottom
|
||||
const nearBottom = window.innerHeight + window.scrollY
|
||||
>= document.body.scrollHeight - tol;
|
||||
if (nearBottom) {
|
||||
localStorage.setItem("wasAtBottom", "1");
|
||||
}
|
||||
location.reload();
|
||||
}
|
||||
};
|
||||
})();
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
{% block content %}
|
||||
{% if metadata.show_title %}
|
||||
<center><h1>{{ metadata.title }}</h1></center>
|
||||
{% include "title.html" %}
|
||||
{% endif %}
|
||||
{{ content | safe }}
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
{% extends "base.html" %}
|
||||
{% extends "base.html" %} {% block content %} {% if metadata.show_title %} {%
|
||||
include "title.html" %} {% if metadata.date %}
|
||||
<center>
|
||||
<time class="post-date" datetime="{{ metadata.date | safe }}"
|
||||
>{{ metadata.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>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
|
|
@ -1,17 +1,20 @@
|
|||
{% extends "base.html" %}
|
||||
{% extends "base.html" %} {% block content %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<center><h1>{{ metadata.title }}</h1></center>
|
||||
{% if metadata.show_title %}
|
||||
{% include "title.html" %}
|
||||
{% endif %}
|
||||
|
||||
<article>{{ content | safe }}</article>
|
||||
|
||||
{% if post_list %}
|
||||
<ul>
|
||||
{% 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>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
<ul>
|
||||
{% for item in post_list %}
|
||||
<li>
|
||||
<time class="post-list-date" datetime="{{ item.metadata.date | safe }}"
|
||||
>{{ item.metadata.date | safe}}</time>: <a href="/{{ item.url }}"
|
||||
>{{ item.metadata.title }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %} {% endblock %}
|
||||
|
||||
|
|
9
src/zona/data/templates/post_nav.html
Normal file
9
src/zona/data/templates/post_nav.html
Normal file
|
@ -0,0 +1,9 @@
|
|||
<div class="post-nav">
|
||||
<center>
|
||||
<span class="symbol"><</span>{% if previous %}<a
|
||||
href="{{ previous.url }}"
|
||||
>prev</a>{% endif %}{% if previous and next %}<span class="symbol"
|
||||
>|</span>{% endif %}{% if next %}<a href="{{ next.url }}">next</a>{% endif
|
||||
%}<span class="symbol">></span>
|
||||
</center>
|
||||
</div>
|
2
src/zona/data/templates/title.html
Normal file
2
src/zona/data/templates/title.html
Normal file
|
@ -0,0 +1,2 @@
|
|||
<center><h1 class="title">{{ metadata.title }}</h1></center>
|
||||
|
|
@ -16,6 +16,22 @@ class Layout:
|
|||
content: Path
|
||||
templates: Path
|
||||
output: Path
|
||||
shared_templates: util.TempDir | None
|
||||
_validate: bool
|
||||
|
||||
def refresh(self):
|
||||
logger.debug("Refreshing layout...")
|
||||
if (
|
||||
self.shared_templates
|
||||
and not self.shared_templates.removed
|
||||
):
|
||||
logger.debug("Removing stale templates tempdir...")
|
||||
self.shared_templates.remove()
|
||||
return self.__class__.from_input(
|
||||
root=self.root,
|
||||
output=self.output,
|
||||
validate=self._validate,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_input(
|
||||
|
@ -31,6 +47,8 @@ class Layout:
|
|||
output=(root / "public").resolve()
|
||||
if not output
|
||||
else output,
|
||||
shared_templates=None,
|
||||
_validate=validate,
|
||||
)
|
||||
if validate:
|
||||
logger.debug("Validating site layout...")
|
||||
|
@ -39,10 +57,29 @@ class Layout:
|
|||
raise FileNotFoundError(
|
||||
"Missing required content directory!"
|
||||
)
|
||||
if not layout.templates.is_dir():
|
||||
internal_templates = util.get_resource_dir("templates")
|
||||
user_templates = layout.templates
|
||||
if not user_templates.is_dir() or util.is_empty(
|
||||
user_templates
|
||||
):
|
||||
logger.debug("Using default template directory.")
|
||||
# use the included defaults
|
||||
layout.templates = util.get_resource_dir("templates")
|
||||
layout.templates = internal_templates
|
||||
else:
|
||||
seen: set[str] = set()
|
||||
temp = util.TempDir()
|
||||
logger.debug(
|
||||
f"Creating shared template directory at {temp}"
|
||||
)
|
||||
for f in user_templates.iterdir():
|
||||
if f.is_file():
|
||||
util.copy_static_file(f, temp.path)
|
||||
seen.add(f.name)
|
||||
for f in internal_templates.iterdir():
|
||||
if f.is_file() and f.name not in seen:
|
||||
util.copy_static_file(f, temp.path)
|
||||
layout.shared_templates = temp
|
||||
layout.templates = temp.path
|
||||
|
||||
return layout
|
||||
|
||||
|
|
|
@ -18,12 +18,15 @@ class Metadata:
|
|||
date: date
|
||||
description: str | None
|
||||
show_title: bool = True
|
||||
show_date: bool = True
|
||||
show_nav: bool = True
|
||||
style: str | None = "/static/style.css"
|
||||
header: bool = True
|
||||
footer: bool = True
|
||||
template: str | None = None
|
||||
post: bool | None = None
|
||||
draft: bool = False
|
||||
ignore: bool = False
|
||||
math: bool = True
|
||||
|
||||
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
@ -21,6 +23,8 @@ class Item:
|
|||
type: ItemType | None = None
|
||||
copy: bool = True
|
||||
post: bool = False
|
||||
next: Item | None = None
|
||||
previous: Item | None = None
|
||||
|
||||
|
||||
# @dataclass
|
||||
|
|
|
@ -13,6 +13,7 @@ from rich import print
|
|||
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from zona import util
|
||||
from zona.builder import ZonaBuilder
|
||||
from zona.log import get_logger
|
||||
from zona.websockets import WebSocketServer
|
||||
|
@ -20,18 +21,23 @@ from zona.websockets import WebSocketServer
|
|||
logger = get_logger()
|
||||
|
||||
|
||||
def make_reload_script(host: str, port: int) -> str:
|
||||
def make_reload_script(
|
||||
host: str, port: int, scroll_tolerance: int
|
||||
) -> str:
|
||||
"""Generates the JavaScript that must be injected into HTML pages for the live reloading to work."""
|
||||
return f"""
|
||||
<script>
|
||||
const ws = new WebSocket("ws://{host}:{port}");
|
||||
ws.onmessage = event => {{
|
||||
if (event.data === "reload") {{
|
||||
location.reload();
|
||||
}}
|
||||
}};
|
||||
</script>
|
||||
"""
|
||||
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"<script>{js}</script>"
|
||||
|
||||
|
||||
def make_handler_class(script: str):
|
||||
|
@ -171,12 +177,13 @@ def serve(
|
|||
draft: bool = True,
|
||||
host: str = "localhost",
|
||||
port: int = 8000,
|
||||
live_reload: bool = True,
|
||||
user_reload: bool | None = None,
|
||||
):
|
||||
"""Serve preview website with live reload and automatic rebuild."""
|
||||
# create temp dir, automatic cleanup
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
builder = ZonaBuilder(root, Path(tmp), draft)
|
||||
config = builder.config
|
||||
# initial site build
|
||||
builder.build()
|
||||
# use discovered paths if none provided
|
||||
|
@ -185,13 +192,21 @@ def serve(
|
|||
if root is None:
|
||||
root = builder.layout.root
|
||||
|
||||
# spin up websocket server for live reloading
|
||||
if live_reload:
|
||||
# use config value unless overridden by user
|
||||
reload = config.server.reload.enabled
|
||||
if user_reload is not None:
|
||||
reload = user_reload
|
||||
if reload:
|
||||
print("Live reloading is enabled.")
|
||||
# spin up websocket server for live reloading
|
||||
ws_port = port + 1
|
||||
ws_server = WebSocketServer(host, ws_port)
|
||||
ws_server.start()
|
||||
# generate reload script for injection
|
||||
reload_script = make_reload_script(host, ws_port)
|
||||
scroll_tolerance = config.server.reload.scroll_tolerance
|
||||
reload_script = make_reload_script(
|
||||
host, ws_port, scroll_tolerance
|
||||
)
|
||||
# generate handler with reload script as attribute
|
||||
handler = make_handler_class(reload_script)
|
||||
else:
|
||||
|
@ -222,9 +237,13 @@ def serve(
|
|||
observer.schedule(
|
||||
event_handler, path=str(root / "content"), recursive=True
|
||||
)
|
||||
observer.schedule(
|
||||
event_handler, path=str(root / "templates"), recursive=True
|
||||
)
|
||||
templates = root / "templates"
|
||||
if templates.is_dir():
|
||||
observer.schedule(
|
||||
event_handler,
|
||||
path=str(templates),
|
||||
recursive=True,
|
||||
)
|
||||
observer.start()
|
||||
|
||||
# function to shut down gracefully
|
||||
|
|
|
@ -3,6 +3,7 @@ from typing import Literal
|
|||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
from zona import util
|
||||
from zona.config import ZonaConfig
|
||||
from zona.markdown import md_to_html
|
||||
from zona.models import Item
|
||||
|
@ -35,6 +36,7 @@ class Templater:
|
|||
template_dir: Path,
|
||||
post_list: list[Item],
|
||||
):
|
||||
# build temporary template dir
|
||||
self.env: Environment = Environment(
|
||||
loader=FileSystemLoader(template_dir),
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
|
@ -76,5 +78,12 @@ class Templater:
|
|||
metadata=meta,
|
||||
header=header,
|
||||
footer=footer,
|
||||
is_post=item.post,
|
||||
next=util.normalize_url(item.next.url)
|
||||
if item.next
|
||||
else None,
|
||||
previous=util.normalize_url(item.previous.url)
|
||||
if item.previous
|
||||
else None,
|
||||
post_list=self.post_list,
|
||||
)
|
||||
|
|
|
@ -1,10 +1,36 @@
|
|||
import fnmatch
|
||||
import re
|
||||
import shutil
|
||||
import string
|
||||
import tempfile
|
||||
import weakref
|
||||
from importlib import resources
|
||||
from importlib.resources.abc import Traversable
|
||||
from pathlib import Path
|
||||
from shutil import copy2
|
||||
from typing import NamedTuple
|
||||
from typing import Any, NamedTuple, override
|
||||
|
||||
|
||||
class TempDir:
|
||||
"""Temporary directory that cleans up when it's garbage collected."""
|
||||
|
||||
def __init__(self):
|
||||
self._tempdir: str = tempfile.mkdtemp()
|
||||
self.path: Path = Path(self._tempdir)
|
||||
self._finalizer: weakref.finalize[Any, Any] = (
|
||||
weakref.finalize(self, shutil.rmtree, self._tempdir)
|
||||
)
|
||||
|
||||
def remove(self):
|
||||
self._finalizer()
|
||||
|
||||
@property
|
||||
def removed(self):
|
||||
return not self._finalizer.alive
|
||||
|
||||
@override
|
||||
def __repr__(self) -> str:
|
||||
return f"<TempDir {self.path}>"
|
||||
|
||||
|
||||
class ZonaResource(NamedTuple):
|
||||
|
@ -12,6 +38,17 @@ class ZonaResource(NamedTuple):
|
|||
contents: str
|
||||
|
||||
|
||||
def get_resource(path: str) -> ZonaResource:
|
||||
"""Load the packaged resource in data/path"""
|
||||
file = resources.files("zona").joinpath("data", path)
|
||||
if file.is_file():
|
||||
return ZonaResource(name=path, contents=file.read_text())
|
||||
else:
|
||||
raise FileNotFoundError(
|
||||
f"{path} is not a valid Zona resource!"
|
||||
)
|
||||
|
||||
|
||||
def get_resources(subdir: str) -> list[ZonaResource]:
|
||||
"""Load the packaged resources in data/subdir"""
|
||||
out: list[ZonaResource] = []
|
||||
|
@ -53,6 +90,15 @@ def copy_static_file(src: Path, dst: Path):
|
|||
copy2(src, dst)
|
||||
|
||||
|
||||
def is_empty(path: Path) -> bool:
|
||||
"""If given a file, check if it has any non-whitespace content.
|
||||
If given a directory, check if it has any children."""
|
||||
if path.is_file():
|
||||
return path.read_text().strip() == ""
|
||||
else:
|
||||
return not any(path.iterdir())
|
||||
|
||||
|
||||
def filename_to_title(path: Path) -> str:
|
||||
name = path.stem
|
||||
words = name.replace("-", " ").replace("_", " ")
|
||||
|
@ -73,3 +119,23 @@ 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()
|
||||
|
|
2
uv.lock
generated
2
uv.lock
generated
|
@ -509,7 +509,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "zona"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "dacite" },
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue