Full Code of capjamesg/aurora for AI

main 597037eee3c9 cached
93 files
167.5 KB
47.5k tokens
68 symbols
1 requests
Download .txt
Repository: capjamesg/aurora
Branch: main
Commit: 597037eee3c9
Files: 93
Total size: 167.5 KB

Directory structure:
gitextract_c93td2o_/

├── .github/
│   ├── ISSUE_TEMPLATE/
│   │   └── bug-report.yml
│   ├── dependabot.yml
│   └── workflows/
│       ├── benchmark.yml
│       ├── docs.yml
│       ├── full-site-tests.yml
│       ├── release.yml
│       ├── test.yml
│       └── welcome.yml
├── .gitignore
├── .pre-commit-config.yaml
├── CHANGELOG.md
├── CITATION.cff
├── LICENSE
├── Makefile
├── README.md
├── aurora/
│   ├── __init__.py
│   ├── cli.py
│   ├── date_helpers.py
│   ├── graph.py
│   └── templates/
│       └── index.html
├── docs/
│   ├── assets/
│   │   ├── prism.css
│   │   └── prism.js
│   ├── config.py
│   ├── highlighting.py
│   ├── pages/
│   │   ├── _layouts/
│   │   │   └── default.html
│   │   └── templates/
│   │       ├── 404.html
│   │       ├── archives.md
│   │       ├── blog.html
│   │       ├── build-methods.md
│   │       ├── collections-from-data.html
│   │       ├── collections.md
│   │       ├── configuration.html
│   │       ├── dates.html
│   │       ├── design.html
│   │       ├── hooks.html
│   │       ├── index.html
│   │       ├── pagination.md
│   │       ├── performance.html
│   │       ├── permalinks.html
│   │       ├── robots.html
│   │       ├── sitemap.html
│   │       ├── start.html
│   │       ├── state.html
│   │       ├── structure.html
│   │       ├── templates.md
│   │       ├── templating.md
│   │       └── users.html
│   └── state.json
├── requirements.txt
├── setup.py
└── tests/
    ├── fixtures/
    │   ├── about.html
    │   ├── about_ISO-8859-1.html
    │   ├── about_UTF-16-BE.html
    │   ├── about_Windows-1252.html
    │   ├── book.html
    │   ├── book_list.html
    │   ├── category_archive.html
    │   ├── collection_pagination.html
    │   ├── date_year.html
    │   ├── date_year_month.html
    │   ├── date_year_month_day.html
    │   ├── index.html
    │   ├── new_site_config.py
    │   ├── post.html
    │   ├── review.html
    │   ├── robots.txt
    │   ├── styles.css
    │   └── tag_archive.html
    ├── library/
    │   ├── assets/
    │   │   ├── meta/
    │   │   │   └── robots.txt
    │   │   └── styles.css
    │   ├── config.py
    │   ├── hooks.py
    │   ├── pages/
    │   │   ├── _data/
    │   │   │   ├── books.json
    │   │   │   └── reviews.csv
    │   │   ├── _layouts/
    │   │   │   ├── book-template.html
    │   │   │   ├── category.html
    │   │   │   ├── date.html
    │   │   │   ├── default.html
    │   │   │   ├── post.html
    │   │   │   ├── reader-review.html
    │   │   │   ├── rooms.html
    │   │   │   └── tag.html
    │   │   ├── posts/
    │   │   │   └── 2024-01-01-first-post.md
    │   │   ├── rooms/
    │   │   │   ├── quiet-corner.html
    │   │   │   └── study-hall.html
    │   │   └── templates/
    │   │       ├── about.html
    │   │       ├── about_ISO-8859-1.html
    │   │       ├── about_UTF-16-BE.html
    │   │       ├── about_Windows-1252.html
    │   │       ├── book_list.html
    │   │       └── index.html
    │   └── state.json
    └── state.py

================================================
FILE CONTENTS
================================================

================================================
FILE: .github/ISSUE_TEMPLATE/bug-report.yml
================================================
name: Bug Report
description: File a bug with Aurora.
labels: [bug]
body:
  - type: markdown
    attributes:
      value: |
        Thank you for submitting a bug report!

  - type: textarea
    attributes:
      label: Bug
      description: Provide a description of the bug you have encountered. If you are running into an error, please include the full error message.
    validations:
      required: true

  - type: textarea
    attributes:
      label: Minimal Reproducible Example
      description: >
        When asking a question, people will be better able to provide help if you provide code that they can easily understand and use to **reproduce** the problem.
        This is referred to by community members as creating a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example).
      placeholder: |
        ```
        # Code to reproduce your issue here
        ```
    validations:
      required: false

  - type: textarea
    attributes:
      label: Additional
      description: Anything else you would like to share?


================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "daily"
  - package-ecosystem: "pip"
    directory: "/"
    schedule:
      interval: "daily"


================================================
FILE: .github/workflows/benchmark.yml
================================================
name: Run benchmark (200k pages+)

on: workflow_dispatch

jobs:
  build:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: ["ubuntu-latest", "macos-latest"]
        python-version: ["3.13"]

    steps:
      - uses: actions/checkout@v6
      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v6
        with:
          python-version: ${{ matrix.python-version }}
          cache: 'pip'
      - name: Install dependencies
        run: |
          python3 -m pip install --upgrade pip
          pip install -e .
          git clone https://github.com/capjamesg/aurora-hn-benchmark
      - name: Build main site
        env:  
          SITE_ENV: ${{ secrets.SITE_ENV }}  
        run: |
          cd aurora-hn-benchmark
          { time aurora build; } 2> time_output.txt
          echo "${{ matrix.os }} - Python ${{ matrix.python-version }}" > performance.txt
          echo "Commit: $(git rev-parse HEAD)" >> time_taken.txt
          cat time_output.txt | grep real | awk '{print $2}' >> performance.txt
          cat performance.txt


================================================
FILE: .github/workflows/docs.yml
================================================
name: Publish documentation

on:
  push:
    branches:
      - main

jobs:
  build:

    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: [3.13]

    steps:
      - uses: actions/checkout@v6
      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v6
        with:
          python-version: ${{ matrix.python-version }}
          cache: 'pip'
      - name: Install dependencies
        run: |
          python3 -m pip install --upgrade pip
          pip install -e .
          pip install pygments bs4 lxml
          cd docs
      - name: Build main site
        env:  
          SITE_ENV: ${{ secrets.SITE_ENV }}  
        run: |
          cd docs
          aurora build
      - name: rsync deployments
        uses: burnett01/rsync-deployments@7.1.0
        with:
          switches: -avzr
          path: "docs/_site/*"
          remote_path: ${{ secrets.SITE_PATH }}
          remote_host: ${{ secrets.SERVER_HOST }}
          remote_user: ${{ secrets.SERVER_USERNAME }}
          remote_key: ${{ secrets.KEY }}


================================================
FILE: .github/workflows/full-site-tests.yml
================================================
name: Test several sites built with Aurora

on:
  push:
    branches:
      - main

jobs:
  build:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: ["ubuntu-latest", "macos-latest"]
        python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]

    steps:
      - uses: actions/checkout@v6
      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v6
        with:
          python-version: ${{ matrix.python-version }}
          cache: 'pip'
      - name: Install dependencies
        run: |
          python3 -m pip install --upgrade pip
          pip install -e .
      - name: Build airport pianos
        env:  
          SITE_ENV: ${{ secrets.SITE_ENV }}  
        run: |
          git clone https://github.com/capjamesg/airport-pianos
          cd airport-pianos
          aurora build
      - name: Build train station pianos
        env:  
          SITE_ENV: ${{ secrets.SITE_ENV }}  
        run: |
          git clone https://github.com/capjamesg/train-station-pianos
          cd train-station-pianos
          aurora build
      - name: Build blog example
        env:  
          SITE_ENV: ${{ secrets.SITE_ENV }}  
        run: |
          git clone https://github.com/capjamesg/aurora-blog-template
          cd aurora-blog-template
          aurora build
      - name: Build docs example
        env:  
          SITE_ENV: ${{ secrets.SITE_ENV }}  
        run: |
          git clone https://github.com/capjamesg/aurora-docs-template
          cd aurora-docs-template
          aurora build


================================================
FILE: .github/workflows/release.yml
================================================
name: Publish WorkFlow

on:
  release:
    types: [created]

jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: [3.8]
    steps:
      - name: 🛎️ Checkout
        uses: actions/checkout@v6
        with:
          ref: ${{ github.head_ref }}
      - name: 🐍 Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v6
        with:
          python-version: ${{ matrix.python-version }}
      - name: 🦾 Install dependencies
        run: |
          python -m pip install --upgrade pip twine wheel
      - name: 🚀 Publish to PyPi
        env:
          PYPI_USERNAME: ${{ secrets.PYPI_USERNAME }}
          PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
        run: |
          python setup.py sdist bdist_wheel
          twine check dist/*
          twine upload dist/* -u ${PYPI_USERNAME} -p ${PYPI_PASSWORD} --verbose


================================================
FILE: .github/workflows/test.yml
================================================
name: Aurora Test Suite

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  build-dev-test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: ["ubuntu-latest", "macos-latest"]
        python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
    steps:
      - name: 🛎️ Checkout
        uses: actions/checkout@v6
      - name: 🐍 Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v6
        with:
          python-version: ${{ matrix.python-version }}
          check-latest: true

      - name: 📦 Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install .
          pip install pytest

      - name: 🧪 Test
        env:
          SITE_ENV: production
        run: "python -m pytest ./tests/state.py"


================================================
FILE: .github/workflows/welcome.yml
================================================
name: Welcome

on: [pull_request, issues]

jobs:
  greeting:
    name: 👋 Welcome
    runs-on: ubuntu-latest
    steps:
      - uses: actions/first-interaction@v3
        with:
          repo-token: ${{ secrets.GITHUB_TOKEN }}
          issue-message: "Thank you for creating an Issue on this repository! 🙌 We will get back to you shortly."
          pr-message: "Thank you for creating an PR on this repository! 🙌 We will get back to you shortly."


================================================
FILE: .gitignore
================================================
# env specific
config.json
.env
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
.idea
# C extensions
*.so

# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
.gptexecthread

# PyInstaller
#  Usually these files are written by a python script from a template
#  before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/

# Translations
*.mo
*.pot

# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal

# Flask stuff:
instance/
.webassets-cache

# Scrapy stuff:
.scrapy

# Sphinx documentation
docs/_build/

# PyBuilder
.pybuilder/
target/

# Jupyter Notebook
.ipynb_checkpoints

# IPython
profile_default/
ipython_config.py

# pyenv
#   For a library or package, you might want to ignore these files since the code is
#   intended to run in multiple environments; otherwise, check them in:
# .python-version

# pipenv
#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
#   However, in case of collaboration, if having platform-specific dependencies or dependencies
#   having no cross-platform support, pipenv may install dependencies that don't work, or not
#   install all needed dependencies.
#Pipfile.lock

# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/

# Celery stuff
celerybeat-schedule
celerybeat.pid

# SageMath parsed files
*.sage.py

# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# Spyder project settings
.spyderproject
.spyproject

# Rope project settings
.ropeproject

# mkdocs documentation
/site

# mypy
.mypy_cache/
.dmypy.json
dmypy.json

# Pyre type checker
.pyre/

# pytype static type analyzer
.pytype/

# Cython debug symbols
cython_debug/

#config stuff
tests/config.json
tests/manual/data

#dataset download stuff

# test/
# train/
# valid/
# data.yaml
README.roboflow.txt
*.zip
.DS_Store
_site/*
docs/_site/*
tests/library/_site/*

================================================
FILE: .pre-commit-config.yaml
================================================



================================================
FILE: CHANGELOG.md
================================================
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.0.4] 2024-05-28

* Add support for data files

## [0.0.2] 2024-05-28

* Fix bug where `page` data was not available in parent templates.
* Add `pages` variable that can be used to see a list of all pages in a template.
* Small performance improvements.
* Fix missing `click` dependency.

## [0.0.1] 2024-05-27

Initial release of Aurora.


================================================
FILE: CITATION.cff
================================================
# This CITATION.cff file was generated with cffinit.
# Visit https://bit.ly/cffinit to generate yours today!

cff-version: 1.2.0
title: Aurora
message: >-
  If you use this software, please cite it using the
  metadata from this file.
type: software
authors:
  - given-names: James
    email: jamesg@jamesg.blog
repository-code: 'https://github.com/capjamesg/aurora'
url: 'https://github.com/capjamesg/aurora'
abstract: >-
   A static site generator implemented in Python. 
keywords:
  - static site generator
  - website
license: MIT


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2024 James

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

================================================
FILE: Makefile
================================================
.PHONY: style check_code_quality

export PYTHONPATH = .
check_dirs := aurora

style:
	black  $(check_dirs)
	isort --profile black $(check_dirs)

check_code_quality:
	black --check $(check_dirs)
	isort --check-only --profile black $(check_dirs)
	# stop the build if there are Python syntax errors or undefined names
	flake8 $(check_dirs) --count --select=E9,F63,F7,F82 --show-source --statistics
	# exit-zero treats all errors as warnings. E203 for black, E501 for docstring, W503 for line breaks before logical operators 
	flake8 $(check_dirs) --count --max-line-length=88 --exit-zero  --ignore=D --extend-ignore=E203,E501,W503  --statistics
	
publish:
	python3 -m build
	twine check dist/*
	twine upload dist/* -u ${PYPI_USERNAME} -p ${PYPI_PASSWORD} --verbose 

================================================
FILE: README.md
================================================
![Banner](banner.png)

<div align="center">

[![version](https://badge.fury.io/py/aurora-ssg.svg)](https://badge.fury.io/py/aurora-ssg)
[![downloads](https://img.shields.io/pypi/dm/aurora-ssg)](https://pypistats.org/packages/aurora-ssg)
[![license](https://img.shields.io/pypi/l/aurora-ssg)](https://github.com/capjamesg/aurora-ssg/blob/main/LICENSE.md)
[![python-version](https://img.shields.io/pypi/pyversions/aurora-ssg)](https://badge.fury.io/py/aurora-ssg)
[![test workflow](https://github.com/capjamesg/aurora/actions/workflows/test.yml/badge.svg)](https://github.com/capjamesg/aurora/actions/workflows/test.yml)
</div>

# Aurora

Aurora is a static site generator implemented in Python.

[See a blog template that you can use with Aurora](https://github.com/capjamesg/aurora-blog-template).

Aurora supports:

- Creating content and pages with markdown, jinja2, and HTML
- Static and incremental builds
- Interactive building with hot reloading for development (up to < 300ms reload time)
- Out-of-the-box support for generating date, category, and tag archive pages

Aurora is supported on Linux and macOS. Aurora does not yet work on Windows, and there may be issues using Aurora on WSL. If you run into any issues with installation, please create a GitHub Issue.

## Demos

### Static Generation (1k+ pages)

https://github.com/capjamesg/aurora/assets/37276661/59e4f3e6-f470-46bd-8812-0b475be40e88

### Incremental Static Regeneration (~40 pages)

https://github.com/capjamesg/aurora/assets/37276661/39f62bd8-cf5f-4d15-a325-7d433b7ceeb0

## Get Started

### Install Aurora

First, install Aurora:

```bash
pip3 install aurora-ssg
```

### Create a Site

To create a new site, run the following command:

```bash
aurora new my-site
```

This will create a folder called `my-site` with everything you need to start your Aurora site.

To navigate to your site, run:

```bash
cd my-site
```

Aurora sites contain a few directories by default:

- `_layouts`: Store templates for your site.
- `assets`: Store static files like images, CSS, and JavaScript.
- `posts`: Store blog posts (optional).
- `pages`: Store static pages to generate.

A new Aurora site will come with a `pages/index.html` file that you can edit to get started.

### Build Your Site (Static)

You can build your site into a static site by running the `aurora build` command.

Aurora works relative to the directory you are in.

To build your site, navigate run the following command:

```bash
aurora build
```

This will generate your site in a `_site` directory.

### Build Your Site (Dynamic)

For development purposes, you can run Aurora with a watcher that will automatically rebuild your site when you make changes to any page in your website.

To run Aurora in watch mode, run the following command:

```bash
aurora serve
```

Your site will be built in the `_site` directory. Any time you make a change to your templates, the `_site` directory will be updated to reflect those changes.

### Development Setup

If you are interested in contributing to Aurora, you will need a local development setup.

To set up your development environment, run the following commands:

```bash
git clone https://github.com/capjamesg/aurora
cd aurora
pip3 install -e .
```

This will install Aurora in editable mode. In editable mode, you can make changes to the code and see them reflected in your local installation.

## Aurora Site Structure

By default, an Aurora site has the following structure in the root directory:

- `pages`: Where all pages used to generate your site are stored.
- `pages/_layouts`: Where you can store layouts for use in generating your website.
- `pages/_data`: Where you can store JSON data files for use in generating pages. See the "Render Collections of Data" section later in this document for information on how to use this directory to generate pages from data files.
- `pages/posts`: Where you can store all of your blog posts, if you use your site as a blog. The posts directory is processed with additional logic to automatically generate date archive and category archive pages, if applicable.

Any file in `pages` or a folder you make in `pages` (not including `_layouts` and `_data`) will be rendered on your website. For example, if you create a `pages/interests/coffee.html` file, this will generate a page called `_site/pages/interests/coffee/index.html`.

## Configuration

You need a `config.py` file in the directory in which you will build your Aurora site. This file is automatically generated when you run `aurora new [site-name]`.

This configuration file defines a few values that Aurora will use when processing your website.

Here is the default `config.py` file, with accompanying comments:

```python
import os

BASE_URLS = {
    "local": os.getcwd(),
}

SITE_ENV = os.environ.get("SITE_ENV", "local")
BASE_URL = BASE_URLS[SITE_ENV]
ROOT_DIR = "pages" # where your site pages are
LAYOUTS_BASE_DIR = "_layouts" # where your site layouts are stored
SITE_DIR = "_site" # the directory in which your site will be saved
REGISTERED_HOOKS = {} # used to register hooks (see `Build Hooks (Advanced)` documentation below for details)
```

The `BASE_URLS` dictionary is used to define the base URL for your site. This is useful if you want to maintain multiple environments for your site (e.g., local, staging, production).

Here is an example configuration of a site that has a local and staging environment:

```python
BASE_URLS = {
    "production": "https://jamesg.blog",
    "staging": "https://staging.jamesg.blog",
    "local": os.getcwd(),
}
```

## Render Collections of Data

You can render data from JSON files as web pages with Aurora. This is useful if you have a JSON collection of data, such as a list of coffee shop reviews, that you want to turn into posts without creating corresponding markdown files.

To create a collection, add a new file to your site's `pages/_data` directory. This file should have a `.json` extension.

Within the file, create a list that contains JSON objects, like this:

```json
[
    {"slug": "rosslyn-coffee", "layout": "coffee", "title": "Rosslyn Coffee in London is terrific."}
]
```

This file is called `pages/_data/coffee.json`.

Every entry must have a `layout` key. This corresponds with the name of the template that will be used to render the page. For example, the `coffee` layout will be rendered using the `pages/_layouts/coffee.html` template.

We need to create the `pages/_layouts/coffee.html` template to render our collection. Create a new file called `pages/_layouts/coffee.html` and add the following contents:

```
---
title: Coffee List
---

{% for item in site.coffee %}
    {{ item.title }}
{% endfor %}
```

Every entry must also have a `slug` key. This corresponds with the name of the page that will be generated. In the case above, one file will be created in the `_site` output directory: `_site/coffee/rosslyn-coffee/index.html`.

## Build Hooks (Advanced)

You can define custom functions that are run before a file is processed by Aurora. You can use this feature to save metadata about a page that can then be consumed by a template.

These functions are called "hooks".

To define a hook, you need to:

1. Write a hook function with the right type signature, and;
2. Add the hook function to the `HOOKS` dictionary in your `config.py` file.

For example, you could define a function that saves the word count of a page:

```python
def word_count_hook(file_name: str, page_state: dict, site_state: dict):
    if "posts/" not in file_name:
        return page_state

    page_state["word_count"] = len(page_state["content"].split())
    return page_state
```

Suppose this is saved in a file called `hooks.py`.

This function would make a `page.word_count` available in the page on which it is run.

Hooks **must** return the `page_state` dictionary, otherwise the page cannot be processed correctly.

To register a hook, create an entry in the `REGISTERED_HOOKS` dictionary in your `config.py` file:

```python
REGISTERED_HOOKS = {
    "hooks": ["word_count_hook"],
}
```

Above, `hooks` corresponds to the name of the Python file with our hook, relative to the directory in which `aurora build` is run. (NB: `aurora build` should always be run in the root directory of your Aurora site.) `word_count_hook` is the name of the function we defined in `hooks.py`.

You can define as many hooks as you want.

To register multiple hooks in the same file, use the syntax:

```python
REGISTERED_HOOKS = {
    "hook_file_name": ["hook1", "hook2", "hook3"],
}
```

## Test Suite

To run the Aurora tests, run:

```
pytest tests/*.py
```

## Performance

In a test generating 292,884 files from a CSV file with a single layer of inheritance in each template, Aurora built the website in 140.59 seconds (2m:20s).

In a test on a website with 1,763 files and multiple layers of inheritance, Aurora built the website in 3.149s. The files in this test were a combination of blog posts, static pages, and programmatic archives for blog posts (date pages, category pages).

In a test rendering 4,000 markdown files with a single layer of inheritance in each template, Aurora built the website in between 0.9 and 1.2 seconds.

In a test comparing 11ty to Aurora in generating the [Airport Pianos](https://github.com/capjamesg/airport-pianos) website (~45 pages), 11ty took 1.36 seconds to start and generate the site, whereas Aurora took 0.034 seconds.

## Users

The following sites are built with Aurora:

- [James' Coffee Blog](https://jamesg.blog) (1,500+ pages)
- [Airport Pianos](https://airportpianos.org) (~45 pages)
- [Train Station Pianos](https://trainstationpianos.org) (~20 pages)

Have you made a website with Aurora? File a PR and add it to the list!

## License

This project is licensed under an [MIT license](LICENSE).


================================================
FILE: aurora/__init__.py
================================================
__version__ = "0.1.7"


================================================
FILE: aurora/cli.py
================================================
import os

import click

from . import __version__


@click.group()
@click.version_option(version=__version__)
def main():
    pass


@click.command("new")
@click.argument("name")
def new(name):
    cli_dir = os.path.dirname(os.path.realpath(__file__))
    if os.path.exists(name):
        print("Site already exists.")
        return

    os.makedirs(name)
    os.chdir(name)

    with open("config.py", "w") as f:
        f.write(
            """import os

BASE_URLS = {
    "local": os.getcwd(),
    "production": "https://example.com",
}

SITE_ENV = os.environ.get("SITE_ENV", "local")
BASE_URL = BASE_URLS[SITE_ENV]
ROOT_DIR = "pages"
LAYOUTS_BASE_DIR = "_layouts"
SITE_DIR = "_site"
HOOKS = {}
SITE_STATE = {}
"""
        )

    os.makedirs("pages")
    os.makedirs("assets")
    os.chdir("pages")
    os.makedirs("_layouts")
    os.makedirs("_data")
    os.makedirs("posts")
    os.makedirs("templates/")

    with open("templates/index.html", "w") as f:
        with open(os.path.join(cli_dir, "templates", "index.html")) as index:
            f.write(index.read())

    os.chdir("..")

    print(f"Site {name} created. ✨")
    print("Run cd/into the site directory.")
    print("Then, `aurora build` to build the site.")
    print("You can also `aurora serve` to start a local server.")


@click.command("build")
@click.option("--incremental", is_flag=True)
def build(incremental):
    from .graph import main as build_site

    # import cProfile
    # cProfile.run("build_site(incremental=incremental)", sort="cumulative")
    print("Building site...")
    build_site(incremental=incremental)
    print("Done! ✨")


@click.command("serve")
def serve():
    from .graph import main as build_site

    build_site(watch=True)


main.add_command(new)
main.add_command(build)
main.add_command(serve)


================================================
FILE: aurora/date_helpers.py
================================================
import datetime

import dateutil.parser


def month_number_to_written_month(month):
    return datetime.datetime.strptime(str(month), "%m").strftime("%B")


def list_archive_date(date):
    if isinstance(date, str):
        date = dateutil.parser.parse(date)

    return date


def long_date(date):
    return list_archive_date(date).strftime("%B %d, %Y")


def date_to_xml_string(date):
    return list_archive_date(date).strftime("%Y-%m-%dT%H:%M:%S")


def archive_date(date):
    return list_archive_date(date).strftime("%Y/%m")


def year(date):
    return list_archive_date(date).strftime("%Y")


================================================
FILE: aurora/graph.py
================================================
import logging
import os
import sys

if not os.path.exists("config.py"):
    raise Exception("config.py not found")

import csv
import datetime
import hashlib
import json
import re
from copy import deepcopy

import chardet
import orjson
import pyromark
import tqdm
from frontmatter import loads
from bs4 import BeautifulSoup
from jinja2 import (
    Environment,
    FileSystemBytecodeCache,
    FileSystemLoader,
    Template,
    meta,
    nodes,
)
from jinja2.visitor import NodeVisitor
from toposort import toposort_flatten
from yaml.reader import ReaderError
from collections import defaultdict

from .date_helpers import (
    archive_date,
    date_to_xml_string,
    list_archive_date,
    long_date,
    month_number_to_written_month,
    year,
)

module_dir = os.getcwd()
os.chdir(module_dir)
sys.path.append(module_dir)
state_to_write = {}
original_file_to_permalink = {}
normalized_collection_permalinks = {}

# print all logs
logging.basicConfig(level=logging.INFO)

from config import (
    BASE_URL,
    HOOKS,
    LAYOUTS_BASE_DIR,
    ROOT_DIR,
    SITE_DIR,
    SITE_STATE,
    SITE_ENV,
)

ALLOWED_EXTENSIONS = ["html", "md", "css", "js", "txt", "xml"]

saved_pages = set()
permalinks = defaultdict(list)
all_data_files = {}
all_pages = []
all_opened_pages = {}
all_page_contents = {}
collections_to_files = {}
all_dependencies = {}
all_parsed_pages = {}
dates = set()
years = {}
reverse_deps = {}
collection_permalinks_to_idx = {}
layout_permalinks_to_idx = {}

# ensures a single template cannot have more than 10 levels of inheritance
INHERITANCE_LIMIT = 10

DATA_FILES_DIR = os.path.join(ROOT_DIR, "_data")

EVALUATED_REGISTERED_TEMPLATE_GENERATION_HOOKS = {}
EVALUATED_POST_TEMPLATE_GENERATION_HOOKS = {}
EVALUATED_POST_BUILD_HOOKS = {}


class Post:
    def __init__(self, front_matter):
        self.__dict__.update(front_matter)

    def __getattr__(self, name):
        return self.__dict__.get(name)

    def serialize_as_json(self):
        """Serialize the Post object as JSON."""
        return orjson.dumps(self.__dict__).decode()


for file_name, hooks in HOOKS.get("pre_template_generation", {}).items():
    EVALUATED_REGISTERED_TEMPLATE_GENERATION_HOOKS[file_name] = [
        getattr(__import__(file_name), func) for func in hooks
    ]

for file_name, hooks in HOOKS.get("post_template_generation", {}).items():
    EVALUATED_POST_TEMPLATE_GENERATION_HOOKS[file_name] = [
        getattr(__import__(file_name), func) for func in hooks
    ]

for file_name, hooks in HOOKS.get("post_build", {}).items():
    EVALUATED_POST_BUILD_HOOKS[file_name] = [
        getattr(__import__(file_name), func) for func in hooks
    ]

today = datetime.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
state = {
    "posts": [],
    "backlinks": defaultdict(list),
    "root_url": BASE_URL,
    "build_date": today.strftime("%m-%d"),
    "pages": [],
    "build_timestamp": datetime.datetime.now().isoformat(),
    "environment": SITE_ENV,
}

file_extensions = {}

state.update(SITE_STATE)

JINJA2_ENV = Environment(
    loader=FileSystemLoader(ROOT_DIR),
    bytecode_cache=FileSystemBytecodeCache(),
)


JINJA2_ENV.filters["long_date"] = long_date
JINJA2_ENV.filters["date_to_xml_string"] = date_to_xml_string
JINJA2_ENV.filters["archive_date"] = archive_date
JINJA2_ENV.filters["list_archive_date"] = list_archive_date
JINJA2_ENV.filters["month_number_to_written_month"] = month_number_to_written_month
JINJA2_ENV.filters["year"] = year

for file_name, hooks in HOOKS.get("template_filters", {}).items():
    for hook in hooks:
        JINJA2_ENV.filters[hook] = getattr(__import__(file_name), hook)

md = pyromark.Markdown(
    options=(
        pyromark.Options.ENABLE_FOOTNOTES
        | pyromark.Options.ENABLE_SMART_PUNCTUATION
        | pyromark.Options.ENABLE_HEADING_ATTRIBUTES
    )
)


def read_file(file_name, mode="r") -> str:
    """
    Read a file and return its contents.
    """
    try:
        with open(file_name, mode) as file:
            return file.read()
    except UnicodeDecodeError as e:
        raw_data = open(file_name, "rb").read()
        result = chardet.detect(raw_data)
        encoding = result["encoding"]

        with open(file_name, "rb") as file:
            return file.read().decode(encoding)
    except Exception as e:
        print(f"Error reading {file_name}")
        raise e


def slugify(value: str) -> str:
    """
    Turn a string into a slug for use in saving data to a file.
    """
    return value.lower().strip().replace(" ", "-")


class VariableVisitor(NodeVisitor):
    """
    Find all variables in a jinja2 template.
    """

    def __init__(self):
        self.variables = set()

    def visit_Name(self, node, *args, **kwargs) -> None:
        self.variables.add(node.name)
        self.generic_visit(node, *args, **kwargs)

    def visit_Getattr(self, node, *args, **kwargs) -> None:
        current_node = node
        variable_chain = []
        while isinstance(current_node, nodes.Getattr):
            variable_chain.append(current_node.attr)
            current_node = current_node.node
        if isinstance(current_node, nodes.Name):
            variable_chain.append(current_node.name)
        full_variable = ".".join(reversed(variable_chain))
        self.variables.add(full_variable)
        self.generic_visit(node, *args, **kwargs)


def get_file_dependencies_and_evaluated_contents(
    file_name: str, contents: Template
) -> tuple:
    """
    Get all dependencies of a file. Dependencies are:

    1. Other files that are included in the file, and;
    2. Variables whose values are defined by the site generator (i.e. `site.*`).
    """
    template = JINJA2_ENV.parse(all_page_contents[file_name])

    includes = []
    included_variables = []

    for node in meta.find_referenced_templates(template):
        includes.append(node)

    visitor = VariableVisitor()
    visitor.visit(template)

    for var in visitor.variables:
        included_variables.append(var)

    dependencies = set()

    for include in includes:
        if isinstance(include, str):
            dependencies.add(os.path.join(ROOT_DIR, include))
        else:
            dependencies.add(os.path.join(ROOT_DIR, include.template.value))

    for variable in included_variables:
        variable = variable.replace("site.", "")

        for collection in collections_to_files:
            if collections_to_files.get(collection):
                dependencies.update(collections_to_files[collection])

        if variable in state:
            dependencies.add(variable)

    parsed_content = all_page_contents[file_name]

    if not parsed_content.get("slug"):
        parsed_content["slug"] = file_name.split("/")[-1].replace(".html", "")

    parsed_content["contents"] = md.html(parsed_content.content)

    parsed_content["url"] = f"{BASE_URL}/{file_name.replace(ROOT_DIR + '/posts/', '')}"

    if parsed_content.metadata.get("permalink") and parsed_content.metadata["permalink"].startswith("/"):
        parsed_content["has_user_assigned_permalink"] = True
        parsed_content["permalink"] = f"/{parsed_content.metadata['permalink'].strip('/')}/"

    parsed_content[
        "permalink"
    ] = f"/{parsed_content.metadata.get('permalink', parsed_content['slug']).strip('/')}/"

    if "categories" not in parsed_content:
        parsed_content["categories"] = []

    slug = file_name.split("/")[-1].replace(".html", "")

    slug = slug.replace("posts/", "")

    if slug[0].isdigit():
        date_slug = re.search(r"\d{4}-\d{2}-\d{2}", slug)

        if date_slug:
            date_slug = date_slug.group(0)
            if not parsed_content.get("post"):
                parsed_content["post"] = {}
            if not parsed_content.get("page"):
                parsed_content["page"] = {}
            parsed_content["post"]["date"] = datetime.datetime.strptime(
                date_slug, "%Y-%m-%d"
            )
            # if "hms" in post metadata, add to post date
            if parsed_content.get("hms") and parsed_content["hms"].count(":") == 2:
                parsed_content["post"]["date"] = parsed_content["post"]["date"].replace(
                    hour=int(parsed_content["hms"].split(":")[0]),
                    minute=int(parsed_content["hms"].split(":")[1]),
                    second=int(parsed_content["hms"].split(":")[2]),
                )

            parsed_content["post"]["date_without_year"] = parsed_content["post"][
                "date"
            ].strftime("%m-%d")
            parsed_content["date_without_year"] = parsed_content["post"][
                "date_without_year"
            ]

            parsed_content["post"]["full_date"] = parsed_content["post"][
                "date"
            ].strftime("%B %d, %Y")
            parsed_content["date"] = parsed_content["post"]["date"]

            parsed_content["page"]["date"] = parsed_content["post"]["date"]

            if "description" not in parsed_content:
                parsed_content["description"] = md.html(
                    parsed_content.content.split("\n")[0]
                )
            date_slug = date_slug.replace("-", "/")
            slug_without_date = re.sub(r"\d{4}-\d{2}-\d{2}-", "", slug)

            parsed_content["post"][
                "url"
            ] = f"{BASE_URL}/{date_slug}/{slug_without_date.replace('.html', '').replace('.md', '')}/"

    if "layout" in parsed_content:
        dependencies.add(
            f"{ROOT_DIR}/{LAYOUTS_BASE_DIR}/{parsed_content['layout']}.html"
        )
        if not state.get(parsed_content["layout"] + "s"):
            state[parsed_content["layout"] + "s"] = []

        if not layout_permalinks_to_idx.get(parsed_content["permalink"]):
            state[parsed_content["layout"] + "s"].append(parsed_content)
            layout_permalinks_to_idx[parsed_content["permalink"]] = (
                len(state[parsed_content["layout"] + "s"]) - 1
            )
        else:
            if (
                len(state[parsed_content["layout"] + "s"])
                > layout_permalinks_to_idx[parsed_content["permalink"]]
            ):
                state[parsed_content["layout"] + "s"][
                    layout_permalinks_to_idx[parsed_content["permalink"]]
                ] = parsed_content

    if "collection" in parsed_content:
        collection_normalized = parsed_content["collection"].lower()
        if not state.get(collection_normalized):
            state[collection_normalized] = []

        if not normalized_collection_permalinks.get(collection_normalized):
            normalized_collection_permalinks[collection_normalized] = []

        # if permalink in collection_permalinks_to_idx, replace
        if collection_permalinks_to_idx.get(parsed_content["permalink"]):
            state[collection_normalized][
                collection_permalinks_to_idx[parsed_content["permalink"]]
            ] = parsed_content
        else:
            state[collection_normalized].append(parsed_content)

        collection_permalinks_to_idx[parsed_content["permalink"]] = state[
            collection_normalized
        ].index(parsed_content)

    return dependencies, parsed_content


def make_any_nonexistent_directories(path: str) -> None:
    if not os.path.exists(path):
        os.makedirs(path)


def interpolate_front_matter(front_matter: dict, state: dict, runtime = None) -> dict:
    """Evaluate front matter with Jinja2 to allow logic in front matter."""
    # Keep track of already interpolated keys to prevent double interpolation
    interpolated_keys = set()
    
    for key in front_matter.keys():
        if (
            isinstance(front_matter[key], str)
            and "{" in front_matter[key]
            and key != "contents"
            and (not front_matter.get("defer_title_evaluation") or runtime == "category")
            and key not in interpolated_keys  # Only interpolate if key hasn't been processed
        ):
            try:
                front_matter[key] = JINJA2_ENV.from_string(front_matter[key]).render(
                    page=front_matter.get("page", front_matter), site=state
                )
                interpolated_keys.add(key)  # Mark this key as interpolated
            except:
                print(f"Error evaluating {front_matter[key]}. ERROR.")
                continue

    return front_matter


def recursively_build_page_template_with_front_matter(
    file_name: str,
    front_matter: dict,
    state: dict,
    current_contents: str = "",
    level: int = 0,
    original_page: dict = None,
) -> str:
    """
    Recursively build a page template with front matter.

    This function is called recursively until there is no layout key in the front matter.
    """
    if level > 10:
        logging.critical(
            f"{file_name} has more than ten levels of recursion. Template will be marked as empty."
        )
        return ""

    if front_matter and "layout" in front_matter.metadata:
        layout = front_matter.metadata["layout"]
        layout_path = f"{ROOT_DIR}/{LAYOUTS_BASE_DIR}/{layout}.html"

        # Always use a deep copy of the current front matter for this recursion
        current_page_metadata = deepcopy(front_matter.metadata)

        # Interpolate front matter before creating page object
        current_page_metadata = interpolate_front_matter(current_page_metadata, state)

        # Create page object with interpolated front matter
        page_fm = type("Page", (object,), current_page_metadata)()

        current_contents = loads(
            all_opened_pages[layout_path].render(
                page=page_fm,
                site=state,
                content=current_contents,
                post=Post(current_page_metadata),
            )
        ).content

        layout_front_matter = all_parsed_pages[layout_path]

        # Pass the current page metadata for the next recursion
        layout_front_matter["page"] = current_page_metadata
        layout_front_matter["post"] = current_page_metadata

        return recursively_build_page_template_with_front_matter(
            file_name, layout_front_matter, state, current_contents.strip(), level + 1, current_page_metadata
        )

    return current_contents


def render_page(file: str, skip_hooks=False) -> None:
    """
    Render a page with the Aurora static site generator.
    """

    original_file = file

    # # skip if json
    if file.endswith(".json"):
        return

    try:
        contents = all_opened_pages[file]
    except Exception as e:
        print(f"Error reading {file}")
        # raise e
        return

    page_state = state.copy()

    has_user_assigned_permalink = False

    if all_parsed_pages[file].get("skip"):
        return

    slug = file.split("/")[-1].replace(".html", "")

    slug = slug.replace("posts/", "")

    has_user_assigned_permalink = all_parsed_pages[file].metadata.get(
        "has_user_assigned_permalink"
    )

    page_state["page"] = all_parsed_pages[file].metadata
    page_state["post"] = all_parsed_pages[file].metadata
    
    if not page_state["page"].get("permalink"):
        page_state["page"]["permalink"] = page_state.get("permalink", slug)  # .strip("/")

    page_state["page"]["generated_on"] = datetime.datetime.now()

    if slug[0].isdigit():
        date_slug = re.search(r"\d{4}-\d{2}-\d{2}", slug)
        if date_slug:
            date_slug = date_slug.group(0)
            page_state["post"]["date"] = datetime.datetime.strptime(
                date_slug, "%Y-%m-%d"
            )
            if page_state["post"].get("hms") and page_state["post"]["hms"].count(":") == 2:
                page_state["post"]["date"] = page_state["post"]["date"].replace(
                    hour=int(page_state["post"]["hms"].split(":")[0]),
                    minute=int(page_state["post"]["hms"].split(":")[1]),
                    second=int(page_state["post"]["hms"].split(":")[2]),
                )
            page_state["post"]["full_date"] = page_state["post"]["date"].strftime(
                "%B %d, %Y"
            )
            page_state["date"] = page_state["post"]["date"]
            page_state["full_date"] = page_state["post"]["full_date"]
            if "description" not in page_state["post"]:
                page_state["post"]["description"] = all_parsed_pages[
                    file
                ].content.split("\n")[0]
        page_state["is_article"] = True

    if page_state.get("date"):
        date = page_state["date"]
        slug = re.sub(r"\d{4}-\d{2}-\d{2}-", "", file)
        slug = slug.replace("pages/posts/", "").replace(".md", "").replace(".html", "")
        page_state["page"]["slug"] = slug
        page_state["page"]["url"] = f"{BASE_URL}/{date.strftime('%Y/%m/%d')}/{slug}/"
    else:
        page_state["page"]["url"] = f"{BASE_URL}/{slug}/"

    page_state["url"] = page_state["page"]["url"]

    if file == "pages/templates/index.html":
        page_state["url"] = BASE_URL
        page_state["page"]["url"] = BASE_URL
        page_state["page"]["permalink"] = BASE_URL

    if not page_state.get("categories"):
        page_state["categories"] = []

    state["categories"] = []

    page_state["page"]["generated_from"] = file

    if page_state.get("page"):
        page_state["page"] = type("Page", (object,), page_state["page"])()
        page_state["post"] = Post(page_state["page"].__dict__)

    for hook, hooks in EVALUATED_REGISTERED_TEMPLATE_GENERATION_HOOKS.items():
        for hook in hooks:
            page_state = hook(file, page_state, state)

    try:
        if file.endswith(".md"):
            contents = md.html(loads(all_opened_pages[file]).content)
        elif isinstance(contents, str):
            # this happens for data files only, where content does not exist
            contents = ""
        else:
            contents = loads(contents.render(page=page_state, site=state)).content
    except Exception as e:
        # print(f"Error rendering {file}")
        return

    page_state["page"].template = file

    rendered = recursively_build_page_template_with_front_matter(
        file, all_parsed_pages[file], page_state, contents
    )

    if not skip_hooks:
        for _, hooks in EVALUATED_POST_TEMPLATE_GENERATION_HOOKS.items():
            for hook in hooks:
                rendered = hook(file, page_state, state, rendered)

    file = file.replace(ROOT_DIR + "/", "")

    if page_state.get("date"):
        file = os.path.join(date.strftime("%Y/%m/%d"), f"{slug}", "index.html")

    if file.endswith(".md"):
        file = file[:-3] + ".html"

    permalink = file

    # if permalink is _site/templates/index.html, make it _site/index.html
    if file == "templates/index.html":
        path = os.path.join(SITE_DIR, "index.html")
        if os.path.exists(path):
            os.remove(path)
        with open(path, "w") as f:
            f.write(rendered)

        return

    if file.startswith("templates/") and any(
        file.endswith(ext) for ext in [".html", ".md"]
    ):
        if hasattr(page_state["page"], "permalink"):
            permalink = os.path.join(
                page_state["page"].permalink.strip("/"), "index.html"
            )
        else:
            permalink = file.replace("templates/", "")
    elif has_user_assigned_permalink:
        permalink = os.path.join(page_state["page"].permalink.strip("/"), "index.html")
    else:
        permalink = file.replace("templates/", "")

    permalink_without_index = permalink.split("index.html")[0]
    final_url = f"{BASE_URL}/{permalink_without_index.rstrip('/')}/"

    if final_url not in saved_pages:
        # if has collections
        state["pages"].append(
            {
                "url": final_url,
                "file": file,
                "rendered_html": contents,
                "noindex": True
                if hasattr(page_state.get("page"), "noindex")
                else False,
                "private": True
                if hasattr(page_state.get("page"), "private")
                else False,
                "title": (
                    page_state["page"].title
                    if page_state.get("page") and hasattr(page_state["page"], "title")
                    else ""
                ),
                "collections": (
                    page_state["page"].collections
                    if hasattr(page_state["page"], "collections")
                    else ""
                ),
                "modified": os.path.getctime(original_file) if os.path.exists(original_file) else 0,
            }
        )
        saved_pages.add(final_url)

    permalinks[permalink].append(file)

    permalink = os.path.join(SITE_DIR, permalink)

    if permalink.endswith(".html"):
        make_any_nonexistent_directories(os.path.dirname(permalink))
    else:
        make_any_nonexistent_directories(os.path.join(SITE_DIR))

    state_to_write[permalink] = rendered
    original_file_to_permalink[permalink] = original_file


def generate_date_page_given_year_month_date(
    ymd_slug, posts, current_date_of_archive, granularity
) -> None:
    ymd_path = os.path.join(SITE_DIR, ymd_slug)

    make_any_nonexistent_directories(ymd_path)

    date_archive_layout = f"{ROOT_DIR}/{LAYOUTS_BASE_DIR}/date.html"

    if not all_opened_pages.get(date_archive_layout):
        return

    date_archive_contents = all_opened_pages[date_archive_layout]

    date_archive_state = state.copy()
    date_archive_state["date"] = current_date_of_archive

    page = deepcopy(all_parsed_pages[date_archive_layout])
    page["date"] = current_date_of_archive
    date_archive_state["date_type"] = granularity

    date_archive_state["posts"] = [all_parsed_pages[post].metadata for post in posts]

    # order by date
    date_archive_state["posts"] = sorted(
        date_archive_state["posts"],
        key=lambda x: x["date"],
        reverse=True,
    )

    fm = interpolate_front_matter(page, date_archive_state)

    rendered_page = date_archive_contents.render(
        date_archive_state,
        site=state,
        posts=date_archive_state["posts"],
        page=date_archive_state,
    )

    if not date_archive_state.get("page"):
        date_archive_state["page"] = {}

    date_archive_state["page"]["template"] = date_archive_layout

    rendered_page = recursively_build_page_template_with_front_matter(
        ymd_path, fm, date_archive_state, loads(rendered_page).content
    )

    with open(
        os.path.join(ymd_path, "index.html"),
        "wb",
        buffering=500,
    ) as f:
        f.write(rendered_page.encode())


def generate_paginated_page_for_collection(
    collection: str, per_page: int, template: str
) -> None:
    """
    Generate paginated pages for a collection.
    """

    if not state.get(collection):
        return

    print(f"Generating paginated pages for {collection}")

    collection = state[collection]

    if not collection:
        return

    all_keys_contain_dates = all(i.metadata.get("date") for i in collection)

    # if all keys have dates
    if all_keys_contain_dates:
        collection = sorted(
            collection, key=lambda x: x.metadata.get("date"), reverse=True
        )
    else:
        collection = sorted(
            collection, key=lambda x: x.metadata.get("title"), reverse=True
        )

    for i in tqdm.tqdm(range(0, len(collection), per_page)):
        page = i // per_page + 1
        paginated_collection = collection[i : i + per_page]

        print(f"Generating paginated page {page} for {collection}")

        if page == 1:
            paginated_collection_path = os.path.join(SITE_DIR, f"{template}/index.html")
        else:
            paginated_collection_path = os.path.join(
                SITE_DIR, f"{template}/{page}/index.html"
            )

        make_any_nonexistent_directories(os.path.dirname(paginated_collection_path))

        paginated_collection_layout = f"{ROOT_DIR}/{LAYOUTS_BASE_DIR}/{template}.html"

        paginated_collection_contents = all_opened_pages[paginated_collection_layout]

        paginated_collection_state = state.copy()
        paginated_collection_state[collection[0]["layout"]] = paginated_collection
        paginated_collection_state["current_page"] = paginated_collection
        paginated_collection_state["page_number"] = page

        page = deepcopy(all_parsed_pages[paginated_collection_layout])
        page[collection[0]["layout"]] = paginated_collection

        fm = interpolate_front_matter(page, paginated_collection_state)

        rendered_page = paginated_collection_contents.render(
            paginated_collection_state,
            site=state,
            posts=paginated_collection,
            page=paginated_collection_state,
        )

        if not paginated_collection_state.get("page"):
            paginated_collection_state["page"] = {}

        paginated_collection_state["page"]["template"] = paginated_collection_layout

        rendered_page = recursively_build_page_template_with_front_matter(
            paginated_collection_path,
            fm,
            paginated_collection_state,
            loads(rendered_page).content,
        )

        with open(
            paginated_collection_path,
            "wb",
            buffering=500,
        ) as f:
            f.write(rendered_page.encode())


def process_date_archives() -> None:
    """
    Generate date archives for all posts.

    For example, if there are posts on 2022-01-01 and 2022-01-02, generate:

    - /2022/index.html
    - /2022/01/index.html
    - /2022/01/01/index.html
    """

    posts = [
        key
        for key in all_opened_pages.keys()
        if key.startswith(os.path.join(ROOT_DIR, "posts"))
    ]

    dates = set()
    years = {}

    for post in posts:
        if not hasattr(all_parsed_pages[post], "metadata"):
            continue

        if not all_parsed_pages[post].metadata.get("date"):
            continue

        date = all_parsed_pages[post].metadata["date"]
        dates.add(date)
        if date.year not in years:
            years[date.year] = {}
        if date.month not in years[date.year]:
            years[date.year][date.month] = {}
        if date.day not in years[date.year][date.month]:
            years[date.year][date.month][date.day] = []
        years[date.year][date.month][date.day].append(post)

    for year in years:
        make_any_nonexistent_directories(os.path.join(SITE_DIR, str(year)))

        for month in years[year]:
            make_any_nonexistent_directories(
                os.path.join(SITE_DIR, str(year), str(month))
            )

            for day in years[year][month]:
                ymd_slug = f"{year}/{str(month).zfill(2)}/{str(day).zfill(2)}"

                generate_date_page_given_year_month_date(
                    ymd_slug,
                    years[year][month][day],
                    datetime.datetime(year, month, day),
                    "day",
                )

            all_posts_in_month = [
                post for day in years[year][month] for post in years[year][month][day]
            ]

            generate_date_page_given_year_month_date(
                f"{year}/{str(month).zfill(2)}",
                all_posts_in_month,
                datetime.datetime(year, month, 1),
                "month",
            )

        all_posts_in_year = [
            post
            for month in years[year]
            for day in years[year][month]
            for post in years[year][month][day]
        ]

        generate_date_page_given_year_month_date(
            str(year), all_posts_in_year, datetime.datetime(year, 1, 1), "year"
        )

        print(f"Generated date archives for {year}")

    state["years"] = years


def process_archives(name: str, state_key_associated_with_name: str, path: str):
    """
    Generate category archives for all posts.

    For example, if you have a post with the `category` key set to `writing`, generate:

    - /writing/index.html
    """
    categories = set()

    for post in state["posts"]:
        if not post.get(state_key_associated_with_name):
            continue

        for category in post[state_key_associated_with_name]:
            categories.add(category)

    for category in categories:
        make_any_nonexistent_directories(
            os.path.join(SITE_DIR, path, slugify(category))
        )

        archive_layout = f"{ROOT_DIR}/{LAYOUTS_BASE_DIR}/{name}.html"
        archive_contents = all_opened_pages[archive_layout]

        archive_state = state.copy()
        archive_state[name] = category
        page = deepcopy(all_parsed_pages[archive_layout])
        page[name] = category
        archive_state["posts"] = [
            post
            for post in state["posts"]
            if category in post.get(state_key_associated_with_name, [])
        ]

        print(f"Generating archive for {category}")

        page["category"] = category

        fm = interpolate_front_matter(page, archive_state, "category")

        fm["url"] = f"{BASE_URL}/{path}/{slugify(category)}/"

        rendered_page = archive_contents.render(
            archive_state,
            site=state,
            posts=archive_state["posts"],
            page=archive_state,
        )

        if not archive_state.get("page"):
            archive_state["page"] = {}

        archive_state["page"]["template"] = archive_layout

        rendered_page = recursively_build_page_template_with_front_matter(
            archive_layout,
            fm,
            archive_state,
            loads(rendered_page).content,
        )

        with open(
            os.path.join(SITE_DIR, path, slugify(category), "index.html"),
            "wb",
            buffering=500,
        ) as f:
            f.write(rendered_page.encode())


def copy_asset_to_site(assets: list) -> None:
    """
    Copy an asset from the `assets` directory to the `_site/assets` directory.
    """
    assets = [asset.replace("./assets/", "") for asset in assets]

    for a in assets:
        print(f"Copying {a} to _site/assets/{a}")
        make_any_nonexistent_directories(os.path.join(SITE_DIR, "assets"))
        asset = read_file(os.path.join("assets", a), "rb")
        with open(os.path.join(SITE_DIR, "assets", a), "wb") as f2:
            f2.write(asset)


def get_state_from_last_build() -> dict:
    """
    Get the state from the last build.
    """
    try:
        data = json.load(open("state.json", "r"))
    except Exception as e:
        print("Error reading state.json. Running a full build.")
        return {}

    return data


def calculate_dependencies_from_saved_state(all_dependencies: dict) -> list:
    """
    Read the saved state and compute dependencies of files that have changed since the last build.
    """
    deps = []

    last_build = datetime.datetime.strptime(
        get_state_from_last_build().get("last_build"), "%Y-%m-%dT%H:%M:%S.%f"
    )

    for root, dirs, files in os.walk(ROOT_DIR):
        # add if has changed since last build
        for file in files:
            path = os.path.join(root, file)
            # must be of parsable extension
            if os.path.splitext(file)[-1].replace(".", "") not in ALLOWED_EXTENSIONS:
                continue

            if os.path.getmtime(path) > last_build.timestamp():
                print(
                    f"Detected change in {path}. Rebuilding this page and its dependencies."
                )

                dependencies_of_dependencies = [
                    i for i in all_dependencies if path in all_dependencies[i]
                ] + [path]
                deps.extend(dependencies_of_dependencies)

    return deps


def load_data_from_data_files(deps: list, data_file_integrity: dict) -> list:
    """
    Read all data files and create YAML file that can be used to generate pages.
    """

    changed_files = []

    for data_file in all_data_files:
        data_dir = data_file.replace(".json", "").replace(".csv", "")
        collections_to_files[data_dir] = []
        idx = 0
        print(f"Loading data from {data_file}...")

        for record in tqdm.tqdm(all_data_files[data_file]):
            if not record.get("slug"):
                # print(
                #     f"Note: {data_file} {record} does not have a 'slug' key. Assigning substitute ID."
                # )
                record["slug"] = str(idx)
                idx += 1

            if not record.get("layout"):
                record["layout"] = data_dir

            slug = record.get("slug")
            path = os.path.join(data_dir, "index.html")

            record_as_string = orjson.dumps(record).decode()

            if (
                data_file_integrity.get(slug)
                != hashlib.sha1(record_as_string.encode()).hexdigest()
            ):
                changed_files.append(path)
                data_file_integrity[slug] = hashlib.sha1(
                    record_as_string.encode()
                ).hexdigest()

            try:
                contents = "---\n" + record_as_string + "\n---\n"
                loaded_contents = loads(contents)
                loaded_contents["skip"] = data_dir in SITE_STATE.get(
                    "disable_collection_single_page_generation", {}
                )
                if "body" in loaded_contents:
                    loaded_contents["content"] = loaded_contents["body"]
                    del loaded_contents["body"]
                all_opened_pages[path] = contents
                all_page_contents[path] = loaded_contents
                all_parsed_pages[path] = loaded_contents
                collections_to_files[data_dir].append(path)
            except ReaderError as e:
                print(
                    f"Error reading {data_file} {record}. This page will not be generated.",
                )
                # delete from all_page_contents
                all_page_contents.pop(path, None)
                all_opened_pages.pop(path, None)
                all_opened_pages.pop(path, None)
                continue

    return changed_files


def get_data_files_in_folder(folder: str) -> list:
    folder = os.path.abspath(folder)  # Convert to absolute path once
    files = []
    for entry in os.listdir(folder):
        path = os.path.join(folder, entry)
        if os.path.isdir(path):
            files.extend(get_data_files_in_folder(path))
        elif os.path.isfile(path) and path.endswith('.json'):
            files.append(path)
    return files


def main(deps: list = [], watch: bool = False, incremental: bool = False) -> None:
    """
    The Aurora runtime.

    Aurora can be run in two ways:

    - `aurora build` to build the site once, and;
    - `aurora serve` to watch for changes in the `pages` directory and rebuild the site in real time.
    """

    global state
    global all_dependencies

    data_file_integrity = {}

    start = datetime.datetime.now()

    if os.path.exists(DATA_FILES_DIR):
        for file in get_data_files_in_folder(DATA_FILES_DIR):
            # remove base /Users/james/src/airport-pianos/pages/
            file = file.replace(os.path.abspath(DATA_FILES_DIR) + "/", "")
            # if dir, recurse
            file_contents = read_file(os.path.join(DATA_FILES_DIR, file))

            if os.path.splitext(file)[-1].replace(".", "") == "json":
                all_data_files[file] = orjson.loads(file_contents)
                if isinstance(all_data_files[file], dict):
                    all_data_files[file] = [
                        {k: v for k, v in all_data_files[file].items()}
                    ]
                state[file.replace(".json", "")] = all_data_files[file]
            elif os.path.splitext(file)[-1].replace(".", "") == "csv":
                all_data_files[file] = list(csv.DictReader(file_contents.split("\n")))
                state[file.replace(".csv", "")] = all_data_files[file]
            else:
                logging.debug(
                    f"Unsupported data file format: {file}", level=logging.CRITICAL
                )
                print(f"Unsupported data file format: {file}")

    if not os.path.exists(SITE_DIR):
        os.makedirs(SITE_DIR)
    else:
        if not deps and not incremental:
            for root, _, files in os.walk(SITE_DIR):
                for file in files:
                    os.remove(os.path.join(root, file))

    for root, _, files in os.walk(ROOT_DIR):
        for file in files:
            ext = os.path.splitext(file)[-1].replace(".", "")
            if ext not in ALLOWED_EXTENSIONS:
                continue

            all_pages.append(os.path.join(root, file))

    for page in all_pages:
        if deps and page not in deps and not incremental:
            continue

        contents = read_file(page)

        try:
            if page.endswith(".md"):
                all_opened_pages[page] = contents
            else:
                all_opened_pages[page] = JINJA2_ENV.from_string(contents)

            all_page_contents[page] = loads(contents)

            if SITE_STATE.get("enable_backlinks"):
                page_links = BeautifulSoup(
                    pyromark.html(contents), "html.parser"
                ).find_all("a", href=True)

                # if in posts/, assign permalink
                if page.startswith("pages/posts/"):
                    # permalink should be YYYY-MM-DD-slug.md turned into /YYYY/MM/DD/slug/
                    yyyy_mm_dd = re.search(r"\d{4}-\d{2}-\d{2}", page)
                    if yyyy_mm_dd:
                        yyyy_mm_dd = yyyy_mm_dd.group(0)
                        slug = page.split(yyyy_mm_dd)[1].replace(".md", "")[1:]
                        yyyy_mm_dd_slug = f"{yyyy_mm_dd.replace('-', '/')}/{slug}"

                    # all_page_contents[page].metadata[
                    #     "permalink"
                    # ] = f"/{yyyy_mm_dd_slug.strip('/')}/"

                all_page_contents[page].metadata["outgoing_links"] = page_links
        except Exception as e:
            # logging.debug(f"Error reading {page}", level=logging.CRITICAL)
            # pass
            raise e

    if SITE_STATE.get("enable_backlinks"):
        for page in all_opened_pages:
            for link in all_page_contents[page].metadata.get("outgoing_links", []):
                state["backlinks"][link["href"]].append(
                    {
                        "url": all_page_contents[page].metadata.get("permalink"),
                        "title": all_page_contents[page].metadata.get("title", ""),
                    }
                )

    # sort all_opened_pages alpha
    all_opened_pages_sorted = list(sorted(all_page_contents.items()))
    # reverse so that we can get next and previous
    all_opened_pages_sorted.reverse()

    for i, page in enumerate(all_opened_pages_sorted):
        # add next and previous page
        if i < len(all_opened_pages_sorted) - 1:
            all_page_contents[page[0]].metadata["previous"] = {
                "url": all_opened_pages_sorted[i + 1][1].metadata.get("permalink", ""),
                "title": all_opened_pages_sorted[i + 1][1].metadata.get("title", ""),
            }

            previous_in_same_category = None
            # look at all posts before i
            for j in range(i + 1, len(all_opened_pages_sorted)):
                # print(f"Comparing {all_opened_pages_sorted[j][1].metadata.get('categories', [])} with {page[1].metadata.get('categories', [])}")
                if all_opened_pages_sorted[j][1].metadata.get("categories", []) == page[
                    1
                ].metadata.get("categories"):
                    previous_in_same_category = all_opened_pages_sorted[j][1]
                    break

            if previous_in_same_category:
                # print(f"Setting previous in same category for {page[1].metadata.get('title')} as {previous_in_same_category.metadata.get('title')} where next is {next_in_same_category.metadata.get('title') if next_in_same_category else None}")
                all_page_contents[page[0]].metadata["previous_in_same_category"] = {
                    "url": previous_in_same_category.metadata.get("permalink", ""),
                    "title": previous_in_same_category.metadata.get("title", ""),
                }

        if i > 0:
            all_page_contents[page[0]].metadata["next"] = {
                "url": all_opened_pages_sorted[i - 1][1].metadata.get("permalink", ""),
                "title": all_opened_pages_sorted[i - 1][1].metadata.get("title", ""),
            }

            next_in_same_category = None

            for j in range(i - 1, -1, -1):
                if all_opened_pages_sorted[j][1].metadata.get("categories", []) == page[
                    1
                ].metadata.get("categories"):
                    next_in_same_category = all_opened_pages_sorted[j][1]
                    break

            if next_in_same_category:
                all_page_contents[page[0]].metadata["next_in_same_category"] = {
                    "url": next_in_same_category.metadata.get("permalink", ""),
                    "title": next_in_same_category.metadata.get("title", ""),
                }

    if deps:
        deps = set(deps)
        new_deps = []

        while deps:
            dep = deps.pop()
            new_deps.append(dep)
            if dep in reverse_deps:
                deps.update(reverse_deps[dep])

        deps = new_deps

    if incremental:
        data = get_state_from_last_build()

        if data != {}:
            data_file_integrity = data.get("data_file_integrity", {})
            changed_files = load_data_from_data_files(deps, data_file_integrity)
            deps.extend(changed_files)
            deps.extend(calculate_dependencies_from_saved_state(all_dependencies))

            if len(deps) == 0:
                print("No changes detected. Exiting.")
                return
        else:
            load_data_from_data_files(deps, data_file_integrity)
    else:
        load_data_from_data_files(deps, data_file_integrity)

    for page, contents in all_opened_pages.items():
        # if incremental, only recompute dependencies for changed files
        if deps and page not in deps and not incremental:
            continue

        dependencies, parsed_page = get_file_dependencies_and_evaluated_contents(
            page, contents
        )
        all_dependencies[page] = dependencies
        all_parsed_pages[page] = parsed_page

        for dependency in dependencies:
            if dependency not in reverse_deps:
                reverse_deps[dependency] = set()
            reverse_deps[dependency].add(page)

        if page.startswith("posts/"):
            state["posts"].append(parsed_page)

    posts = [
        key
        for key in all_opened_pages.keys()
        if key.startswith(os.path.join(ROOT_DIR, "/posts"))
    ]

    for post in posts:
        if not hasattr(all_parsed_pages[post], "metadata"):
            continue

        if all_parsed_pages[post].metadata.get("date"):
            date = all_parsed_pages[post].metadata["date"]
            dates.add(date)
            if date.year not in years:
                years[date.year] = {}
            if date.month not in years[date.year]:
                years[date.year][date.month] = {}
            if date.day not in years[date.year][date.month]:
                years[date.year][date.month][date.day] = []
            years[date.year][date.month][date.day].append(post)

    state["years"] = years

    state["posts"] = sorted(
        state["posts"],
        # if post has "hms", sort by slug date then hms; otherwise, sort by slug
        key=lambda x: (
            "-".join(x.metadata["slug"].split("-")[:3]) + "-" + x.metadata.get("hms", "")
            if x.metadata.get("hms")
            else x["slug"]
        ),
        reverse=True,
    )

    all_dependencies = {
        k: v for k, v in all_dependencies.items() if not k.startswith("pages/_")
    }

    dependencies = (
        deps
        if incremental and len(deps) > 0
        else list(toposort_flatten(all_dependencies))
    )

    dependencies = [
        dependency
        for dependency in dependencies
        if not dependency.startswith("pages/_")
    ]

    if watch:
        iterator = dependencies
    else:
        iterator = tqdm.tqdm(dependencies)

    iterator_set = set(iterator)

    print("Generating pages in memory...")

    for file in iterator:
        if os.path.isdir(file):
            for root, _, files in os.walk(file):
                for file in files:
                    file_path = os.path.join(root, file)
                    if file_path not in iterator_set:
                        render_page(file_path, skip_hooks=watch)
        else:
            render_page(file, skip_hooks=watch)

    print("Saving files to disk...")

    if not incremental:
        for root, _, files in os.walk("assets"):
            for file in files:
                path = os.path.join(SITE_DIR, root)
                if not os.path.exists(path):
                    os.makedirs(path)
                contents = read_file(os.path.join(root, file), "rb")

                with open(os.path.join(SITE_DIR, root, file), "wb") as f2:
                    f2.write(contents)

    if incremental and deps:
        for file in tqdm.tqdm(state_to_write):
            if original_file_to_permalink.get(file) in deps:
                with open(file, "wb", buffering=1000) as f:
                    f.write(state_to_write[file].encode())
    else:
        for file in tqdm.tqdm(state_to_write):
            with open(file, "wb", buffering=1000) as f:
                f.write(state_to_write[file].encode())

    if any(k.startswith("pages/") for k in all_dependencies):
        if "skip_date_archive_page_generation" not in SITE_STATE:
            process_date_archives()
        if "skip_category_page_generation" not in SITE_STATE:
            process_archives(
                SITE_STATE.get("category_template", "category"),
                "categories",
                SITE_STATE.get("category_slug_root", "category"),
            )
        if "skip_tag_page_generation" not in SITE_STATE:
            process_archives(
                SITE_STATE.get("tag_template", "tag"),
                "tags",
                SITE_STATE.get("tag_slug_root", "tag"),
            )

    for collection_name, attributes in SITE_STATE.get("paginators", {}).items():
        generate_paginated_page_for_collection(
            collection_name, attributes["per_page"], attributes["template"]
        )

    for hooks in EVALUATED_POST_BUILD_HOOKS.values():
        for hook in hooks:
            hook(state)

    if incremental:
        to_save = {
            "last_build": state["build_timestamp"],
            "data_file_integrity": data_file_integrity,
        }

        json.dump(to_save, open("state.json", "w"))

    print(
        f"Built site in \033[94m{(datetime.datetime.now() - start).total_seconds():.3f}s\033[0m ✨\n"
    )

    if watch:
        from livereload import Server

        srv = Server()

        for permalink, files in permalinks.items():
            if len(files) > 1:
                yellow = "\033[93m"
                print(
                    f"{yellow}Warning: {permalink} has multiple files: {files}{yellow}"
                )

        # logging.disable(logging.INFO)

        print("Live reload mode enabled.\nWatching for changes...\n")
        print("View your site at \033[92mhttp://localhost:8000\033[0m")
        print("Press Ctrl+C to stop.")

        srv.watch(ROOT_DIR, lambda: main(deps=[srv.watcher.filepath], incremental=True))
        srv.watch("./assets", lambda: copy_asset_to_site([srv.watcher.filepath]))
        srv.serve(root=SITE_DIR, liveport=35729, port=8000, debug=False)
    else:
        for permalink, files in permalinks.items():
            if len(files) > 1:
                yellow = "\033[93m"
                print(
                    f"{yellow}Warning: {permalink} has multiple files: {files}{yellow}"
                )


================================================
FILE: aurora/templates/index.html
================================================
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Welcome to your website!</title>
        <style>
            body {
                font-family: Arial, sans-serif;
                margin: 2em;
            }
            h1 {
                color: #333;
            }
            ul {
                list-style-type: none;
                padding: 0;
                display: grid;
                grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
            }
            li {
                margin-bottom: 1em;
                background-color: #f7f7f7;
                border-radius: 1em;
                padding: 1em;
            }
            a {
                color: blueviolet;
                text-decoration: none;
            }
            a:hover {
                text-decoration: underline;
            }
        </style>
    </head>
    <body>
        <h1>Welcome to your new website! 🤗</h1>
        <p>Below are some helpful links to get you started in making your site with Aurora.</p>
        <ul>
            <li>
                <h2><a href="https://github.com/capjamesg/aurora">Read the Aurora documentation.</a></h2>
            </li>
        </ul>
    </body>
</html>


================================================
FILE: docs/assets/prism.css
================================================
/* PrismJS 1.29.0
https://prismjs.com/download.html#themes=prism&languages=markup+bash+python */
code[class*=language-],pre[class*=language-]{color:#000;background:0 0;text-shadow:0 1px #fff;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}code[class*=language-] ::-moz-selection,code[class*=language-]::-moz-selection,pre[class*=language-] ::-moz-selection,pre[class*=language-]::-moz-selection{text-shadow:none;background:#b3d4fc}code[class*=language-] ::selection,code[class*=language-]::selection,pre[class*=language-] ::selection,pre[class*=language-]::selection{text-shadow:none;background:#b3d4fc}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#f5f2f0}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#708090}.token.punctuation{color:#999}.token.namespace{opacity:.7}.token.boolean,.token.constant,.token.deleted,.token.number,.token.property,.token.symbol,.token.tag{color:#905}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#690}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url{color:#9a6e3a;background:hsla(0,0%,100%,.5)}.token.atrule,.token.attr-value,.token.keyword{color:#07a}.token.class-name,.token.function{color:#dd4a68}.token.important,.token.regex,.token.variable{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}


================================================
FILE: docs/assets/prism.js
================================================
/* PrismJS 1.29.0
https://prismjs.com/download.html#themes=prism&languages=markup+bash+python */
var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(e){var n=/(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i,t=0,r={},a={manual:e.Prism&&e.Prism.manual,disableWorkerMessageHandler:e.Prism&&e.Prism.disableWorkerMessageHandler,util:{encode:function e(n){return n instanceof i?new i(n.type,e(n.content),n.alias):Array.isArray(n)?n.map(e):n.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/\u00a0/g," ")},type:function(e){return Object.prototype.toString.call(e).slice(8,-1)},objId:function(e){return e.__id||Object.defineProperty(e,"__id",{value:++t}),e.__id},clone:function e(n,t){var r,i;switch(t=t||{},a.util.type(n)){case"Object":if(i=a.util.objId(n),t[i])return t[i];for(var l in r={},t[i]=r,n)n.hasOwnProperty(l)&&(r[l]=e(n[l],t));return r;case"Array":return i=a.util.objId(n),t[i]?t[i]:(r=[],t[i]=r,n.forEach((function(n,a){r[a]=e(n,t)})),r);default:return n}},getLanguage:function(e){for(;e;){var t=n.exec(e.className);if(t)return t[1].toLowerCase();e=e.parentElement}return"none"},setLanguage:function(e,t){e.className=e.className.replace(RegExp(n,"gi"),""),e.classList.add("language-"+t)},currentScript:function(){if("undefined"==typeof document)return null;if("currentScript"in document)return document.currentScript;try{throw new Error}catch(r){var e=(/at [^(\r\n]*\((.*):[^:]+:[^:]+\)$/i.exec(r.stack)||[])[1];if(e){var n=document.getElementsByTagName("script");for(var t in n)if(n[t].src==e)return n[t]}return null}},isActive:function(e,n,t){for(var r="no-"+n;e;){var a=e.classList;if(a.contains(n))return!0;if(a.contains(r))return!1;e=e.parentElement}return!!t}},languages:{plain:r,plaintext:r,text:r,txt:r,extend:function(e,n){var t=a.util.clone(a.languages[e]);for(var r in n)t[r]=n[r];return t},insertBefore:function(e,n,t,r){var i=(r=r||a.languages)[e],l={};for(var o in i)if(i.hasOwnProperty(o)){if(o==n)for(var s in t)t.hasOwnProperty(s)&&(l[s]=t[s]);t.hasOwnProperty(o)||(l[o]=i[o])}var u=r[e];return r[e]=l,a.languages.DFS(a.languages,(function(n,t){t===u&&n!=e&&(this[n]=l)})),l},DFS:function e(n,t,r,i){i=i||{};var l=a.util.objId;for(var o in n)if(n.hasOwnProperty(o)){t.call(n,o,n[o],r||o);var s=n[o],u=a.util.type(s);"Object"!==u||i[l(s)]?"Array"!==u||i[l(s)]||(i[l(s)]=!0,e(s,t,o,i)):(i[l(s)]=!0,e(s,t,null,i))}}},plugins:{},highlightAll:function(e,n){a.highlightAllUnder(document,e,n)},highlightAllUnder:function(e,n,t){var r={callback:t,container:e,selector:'code[class*="language-"], [class*="language-"] code, code[class*="lang-"], [class*="lang-"] code'};a.hooks.run("before-highlightall",r),r.elements=Array.prototype.slice.apply(r.container.querySelectorAll(r.selector)),a.hooks.run("before-all-elements-highlight",r);for(var i,l=0;i=r.elements[l++];)a.highlightElement(i,!0===n,r.callback)},highlightElement:function(n,t,r){var i=a.util.getLanguage(n),l=a.languages[i];a.util.setLanguage(n,i);var o=n.parentElement;o&&"pre"===o.nodeName.toLowerCase()&&a.util.setLanguage(o,i);var s={element:n,language:i,grammar:l,code:n.textContent};function u(e){s.highlightedCode=e,a.hooks.run("before-insert",s),s.element.innerHTML=s.highlightedCode,a.hooks.run("after-highlight",s),a.hooks.run("complete",s),r&&r.call(s.element)}if(a.hooks.run("before-sanity-check",s),(o=s.element.parentElement)&&"pre"===o.nodeName.toLowerCase()&&!o.hasAttribute("tabindex")&&o.setAttribute("tabindex","0"),!s.code)return a.hooks.run("complete",s),void(r&&r.call(s.element));if(a.hooks.run("before-highlight",s),s.grammar)if(t&&e.Worker){var c=new Worker(a.filename);c.onmessage=function(e){u(e.data)},c.postMessage(JSON.stringify({language:s.language,code:s.code,immediateClose:!0}))}else u(a.highlight(s.code,s.grammar,s.language));else u(a.util.encode(s.code))},highlight:function(e,n,t){var r={code:e,grammar:n,language:t};if(a.hooks.run("before-tokenize",r),!r.grammar)throw new Error('The language "'+r.language+'" has no grammar.');return r.tokens=a.tokenize(r.code,r.grammar),a.hooks.run("after-tokenize",r),i.stringify(a.util.encode(r.tokens),r.language)},tokenize:function(e,n){var t=n.rest;if(t){for(var r in t)n[r]=t[r];delete n.rest}var a=new s;return u(a,a.head,e),o(e,a,n,a.head,0),function(e){for(var n=[],t=e.head.next;t!==e.tail;)n.push(t.value),t=t.next;return n}(a)},hooks:{all:{},add:function(e,n){var t=a.hooks.all;t[e]=t[e]||[],t[e].push(n)},run:function(e,n){var t=a.hooks.all[e];if(t&&t.length)for(var r,i=0;r=t[i++];)r(n)}},Token:i};function i(e,n,t,r){this.type=e,this.content=n,this.alias=t,this.length=0|(r||"").length}function l(e,n,t,r){e.lastIndex=n;var a=e.exec(t);if(a&&r&&a[1]){var i=a[1].length;a.index+=i,a[0]=a[0].slice(i)}return a}function o(e,n,t,r,s,g){for(var f in t)if(t.hasOwnProperty(f)&&t[f]){var h=t[f];h=Array.isArray(h)?h:[h];for(var d=0;d<h.length;++d){if(g&&g.cause==f+","+d)return;var v=h[d],p=v.inside,m=!!v.lookbehind,y=!!v.greedy,k=v.alias;if(y&&!v.pattern.global){var x=v.pattern.toString().match(/[imsuy]*$/)[0];v.pattern=RegExp(v.pattern.source,x+"g")}for(var b=v.pattern||v,w=r.next,A=s;w!==n.tail&&!(g&&A>=g.reach);A+=w.value.length,w=w.next){var E=w.value;if(n.length>e.length)return;if(!(E instanceof i)){var P,L=1;if(y){if(!(P=l(b,A,e,m))||P.index>=e.length)break;var S=P.index,O=P.index+P[0].length,j=A;for(j+=w.value.length;S>=j;)j+=(w=w.next).value.length;if(A=j-=w.value.length,w.value instanceof i)continue;for(var C=w;C!==n.tail&&(j<O||"string"==typeof C.value);C=C.next)L++,j+=C.value.length;L--,E=e.slice(A,j),P.index-=A}else if(!(P=l(b,0,E,m)))continue;S=P.index;var N=P[0],_=E.slice(0,S),M=E.slice(S+N.length),W=A+E.length;g&&W>g.reach&&(g.reach=W);var z=w.prev;if(_&&(z=u(n,z,_),A+=_.length),c(n,z,L),w=u(n,z,new i(f,p?a.tokenize(N,p):N,k,N)),M&&u(n,w,M),L>1){var I={cause:f+","+d,reach:W};o(e,n,t,w.prev,A,I),g&&I.reach>g.reach&&(g.reach=I.reach)}}}}}}function s(){var e={value:null,prev:null,next:null},n={value:null,prev:e,next:null};e.next=n,this.head=e,this.tail=n,this.length=0}function u(e,n,t){var r=n.next,a={value:t,prev:n,next:r};return n.next=a,r.prev=a,e.length++,a}function c(e,n,t){for(var r=n.next,a=0;a<t&&r!==e.tail;a++)r=r.next;n.next=r,r.prev=n,e.length-=a}if(e.Prism=a,i.stringify=function e(n,t){if("string"==typeof n)return n;if(Array.isArray(n)){var r="";return n.forEach((function(n){r+=e(n,t)})),r}var i={type:n.type,content:e(n.content,t),tag:"span",classes:["token",n.type],attributes:{},language:t},l=n.alias;l&&(Array.isArray(l)?Array.prototype.push.apply(i.classes,l):i.classes.push(l)),a.hooks.run("wrap",i);var o="";for(var s in i.attributes)o+=" "+s+'="'+(i.attributes[s]||"").replace(/"/g,"&quot;")+'"';return"<"+i.tag+' class="'+i.classes.join(" ")+'"'+o+">"+i.content+"</"+i.tag+">"},!e.document)return e.addEventListener?(a.disableWorkerMessageHandler||e.addEventListener("message",(function(n){var t=JSON.parse(n.data),r=t.language,i=t.code,l=t.immediateClose;e.postMessage(a.highlight(i,a.languages[r],r)),l&&e.close()}),!1),a):a;var g=a.util.currentScript();function f(){a.manual||a.highlightAll()}if(g&&(a.filename=g.src,g.hasAttribute("data-manual")&&(a.manual=!0)),!a.manual){var h=document.readyState;"loading"===h||"interactive"===h&&g&&g.defer?document.addEventListener("DOMContentLoaded",f):window.requestAnimationFrame?window.requestAnimationFrame(f):window.setTimeout(f,16)}return a}(_self);"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism);
Prism.languages.markup={comment:{pattern:/<!--(?:(?!<!--)[\s\S])*?-->/,greedy:!0},prolog:{pattern:/<\?[\s\S]+?\?>/,greedy:!0},doctype:{pattern:/<!DOCTYPE(?:[^>"'[\]]|"[^"]*"|'[^']*')+(?:\[(?:[^<"'\]]|"[^"]*"|'[^']*'|<(?!!--)|<!--(?:[^-]|-(?!->))*-->)*\]\s*)?>/i,greedy:!0,inside:{"internal-subset":{pattern:/(^[^\[]*\[)[\s\S]+(?=\]>$)/,lookbehind:!0,greedy:!0,inside:null},string:{pattern:/"[^"]*"|'[^']*'/,greedy:!0},punctuation:/^<!|>$|[[\]]/,"doctype-tag":/^DOCTYPE/i,name:/[^\s<>'"]+/}},cdata:{pattern:/<!\[CDATA\[[\s\S]*?\]\]>/i,greedy:!0},tag:{pattern:/<\/?(?!\d)[^\s>\/=$<%]+(?:\s(?:\s*[^\s>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))|(?=[\s/>])))+)?\s*\/?>/,greedy:!0,inside:{tag:{pattern:/^<\/?[^\s>\/]+/,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"special-attr":[],"attr-value":{pattern:/=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+)/,inside:{punctuation:[{pattern:/^=/,alias:"attr-equals"},{pattern:/^(\s*)["']|["']$/,lookbehind:!0}]}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:[{pattern:/&[\da-z]{1,8};/i,alias:"named-entity"},/&#x?[\da-f]{1,8};/i]},Prism.languages.markup.tag.inside["attr-value"].inside.entity=Prism.languages.markup.entity,Prism.languages.markup.doctype.inside["internal-subset"].inside=Prism.languages.markup,Prism.hooks.add("wrap",(function(a){"entity"===a.type&&(a.attributes.title=a.content.replace(/&amp;/,"&"))})),Object.defineProperty(Prism.languages.markup.tag,"addInlined",{value:function(a,e){var s={};s["language-"+e]={pattern:/(^<!\[CDATA\[)[\s\S]+?(?=\]\]>$)/i,lookbehind:!0,inside:Prism.languages[e]},s.cdata=/^<!\[CDATA\[|\]\]>$/i;var t={"included-cdata":{pattern:/<!\[CDATA\[[\s\S]*?\]\]>/i,inside:s}};t["language-"+e]={pattern:/[\s\S]+/,inside:Prism.languages[e]};var n={};n[a]={pattern:RegExp("(<__[^>]*>)(?:<!\\[CDATA\\[(?:[^\\]]|\\](?!\\]>))*\\]\\]>|(?!<!\\[CDATA\\[)[^])*?(?=</__>)".replace(/__/g,(function(){return a})),"i"),lookbehind:!0,greedy:!0,inside:t},Prism.languages.insertBefore("markup","cdata",n)}}),Object.defineProperty(Prism.languages.markup.tag,"addAttribute",{value:function(a,e){Prism.languages.markup.tag.inside["special-attr"].push({pattern:RegExp("(^|[\"'\\s])(?:"+a+")\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+(?=[\\s>]))","i"),lookbehind:!0,inside:{"attr-name":/^[^\s=]+/,"attr-value":{pattern:/=[\s\S]+/,inside:{value:{pattern:/(^=\s*(["']|(?!["'])))\S[\s\S]*(?=\2$)/,lookbehind:!0,alias:[e,"language-"+e],inside:Prism.languages[e]},punctuation:[{pattern:/^=/,alias:"attr-equals"},/"|'/]}}}})}}),Prism.languages.html=Prism.languages.markup,Prism.languages.mathml=Prism.languages.markup,Prism.languages.svg=Prism.languages.markup,Prism.languages.xml=Prism.languages.extend("markup",{}),Prism.languages.ssml=Prism.languages.xml,Prism.languages.atom=Prism.languages.xml,Prism.languages.rss=Prism.languages.xml;
!function(e){var t="\\b(?:BASH|BASHOPTS|BASH_ALIASES|BASH_ARGC|BASH_ARGV|BASH_CMDS|BASH_COMPLETION_COMPAT_DIR|BASH_LINENO|BASH_REMATCH|BASH_SOURCE|BASH_VERSINFO|BASH_VERSION|COLORTERM|COLUMNS|COMP_WORDBREAKS|DBUS_SESSION_BUS_ADDRESS|DEFAULTS_PATH|DESKTOP_SESSION|DIRSTACK|DISPLAY|EUID|GDMSESSION|GDM_LANG|GNOME_KEYRING_CONTROL|GNOME_KEYRING_PID|GPG_AGENT_INFO|GROUPS|HISTCONTROL|HISTFILE|HISTFILESIZE|HISTSIZE|HOME|HOSTNAME|HOSTTYPE|IFS|INSTANCE|JOB|LANG|LANGUAGE|LC_ADDRESS|LC_ALL|LC_IDENTIFICATION|LC_MEASUREMENT|LC_MONETARY|LC_NAME|LC_NUMERIC|LC_PAPER|LC_TELEPHONE|LC_TIME|LESSCLOSE|LESSOPEN|LINES|LOGNAME|LS_COLORS|MACHTYPE|MAILCHECK|MANDATORY_PATH|NO_AT_BRIDGE|OLDPWD|OPTERR|OPTIND|ORBIT_SOCKETDIR|OSTYPE|PAPERSIZE|PATH|PIPESTATUS|PPID|PS1|PS2|PS3|PS4|PWD|RANDOM|REPLY|SECONDS|SELINUX_INIT|SESSION|SESSIONTYPE|SESSION_MANAGER|SHELL|SHELLOPTS|SHLVL|SSH_AUTH_SOCK|TERM|UID|UPSTART_EVENTS|UPSTART_INSTANCE|UPSTART_JOB|UPSTART_SESSION|USER|WINDOWID|XAUTHORITY|XDG_CONFIG_DIRS|XDG_CURRENT_DESKTOP|XDG_DATA_DIRS|XDG_GREETER_DATA_DIR|XDG_MENU_PREFIX|XDG_RUNTIME_DIR|XDG_SEAT|XDG_SEAT_PATH|XDG_SESSION_DESKTOP|XDG_SESSION_ID|XDG_SESSION_PATH|XDG_SESSION_TYPE|XDG_VTNR|XMODIFIERS)\\b",a={pattern:/(^(["']?)\w+\2)[ \t]+\S.*/,lookbehind:!0,alias:"punctuation",inside:null},n={bash:a,environment:{pattern:RegExp("\\$"+t),alias:"constant"},variable:[{pattern:/\$?\(\([\s\S]+?\)\)/,greedy:!0,inside:{variable:[{pattern:/(^\$\(\([\s\S]+)\)\)/,lookbehind:!0},/^\$\(\(/],number:/\b0x[\dA-Fa-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:[Ee]-?\d+)?/,operator:/--|\+\+|\*\*=?|<<=?|>>=?|&&|\|\||[=!+\-*/%<>^&|]=?|[?~:]/,punctuation:/\(\(?|\)\)?|,|;/}},{pattern:/\$\((?:\([^)]+\)|[^()])+\)|`[^`]+`/,greedy:!0,inside:{variable:/^\$\(|^`|\)$|`$/}},{pattern:/\$\{[^}]+\}/,greedy:!0,inside:{operator:/:[-=?+]?|[!\/]|##?|%%?|\^\^?|,,?/,punctuation:/[\[\]]/,environment:{pattern:RegExp("(\\{)"+t),lookbehind:!0,alias:"constant"}}},/\$(?:\w+|[#?*!@$])/],entity:/\\(?:[abceEfnrtv\\"]|O?[0-7]{1,3}|U[0-9a-fA-F]{8}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{1,2})/};e.languages.bash={shebang:{pattern:/^#!\s*\/.*/,alias:"important"},comment:{pattern:/(^|[^"{\\$])#.*/,lookbehind:!0},"function-name":[{pattern:/(\bfunction\s+)[\w-]+(?=(?:\s*\(?:\s*\))?\s*\{)/,lookbehind:!0,alias:"function"},{pattern:/\b[\w-]+(?=\s*\(\s*\)\s*\{)/,alias:"function"}],"for-or-select":{pattern:/(\b(?:for|select)\s+)\w+(?=\s+in\s)/,alias:"variable",lookbehind:!0},"assign-left":{pattern:/(^|[\s;|&]|[<>]\()\w+(?:\.\w+)*(?=\+?=)/,inside:{environment:{pattern:RegExp("(^|[\\s;|&]|[<>]\\()"+t),lookbehind:!0,alias:"constant"}},alias:"variable",lookbehind:!0},parameter:{pattern:/(^|\s)-{1,2}(?:\w+:[+-]?)?\w+(?:\.\w+)*(?=[=\s]|$)/,alias:"variable",lookbehind:!0},string:[{pattern:/((?:^|[^<])<<-?\s*)(\w+)\s[\s\S]*?(?:\r?\n|\r)\2/,lookbehind:!0,greedy:!0,inside:n},{pattern:/((?:^|[^<])<<-?\s*)(["'])(\w+)\2\s[\s\S]*?(?:\r?\n|\r)\3/,lookbehind:!0,greedy:!0,inside:{bash:a}},{pattern:/(^|[^\\](?:\\\\)*)"(?:\\[\s\S]|\$\([^)]+\)|\$(?!\()|`[^`]+`|[^"\\`$])*"/,lookbehind:!0,greedy:!0,inside:n},{pattern:/(^|[^$\\])'[^']*'/,lookbehind:!0,greedy:!0},{pattern:/\$'(?:[^'\\]|\\[\s\S])*'/,greedy:!0,inside:{entity:n.entity}}],environment:{pattern:RegExp("\\$?"+t),alias:"constant"},variable:n.variable,function:{pattern:/(^|[\s;|&]|[<>]\()(?:add|apropos|apt|apt-cache|apt-get|aptitude|aspell|automysqlbackup|awk|basename|bash|bc|bconsole|bg|bzip2|cal|cargo|cat|cfdisk|chgrp|chkconfig|chmod|chown|chroot|cksum|clear|cmp|column|comm|composer|cp|cron|crontab|csplit|curl|cut|date|dc|dd|ddrescue|debootstrap|df|diff|diff3|dig|dir|dircolors|dirname|dirs|dmesg|docker|docker-compose|du|egrep|eject|env|ethtool|expand|expect|expr|fdformat|fdisk|fg|fgrep|file|find|fmt|fold|format|free|fsck|ftp|fuser|gawk|git|gparted|grep|groupadd|groupdel|groupmod|groups|grub-mkconfig|gzip|halt|head|hg|history|host|hostname|htop|iconv|id|ifconfig|ifdown|ifup|import|install|ip|java|jobs|join|kill|killall|less|link|ln|locate|logname|logrotate|look|lpc|lpr|lprint|lprintd|lprintq|lprm|ls|lsof|lynx|make|man|mc|mdadm|mkconfig|mkdir|mke2fs|mkfifo|mkfs|mkisofs|mknod|mkswap|mmv|more|most|mount|mtools|mtr|mutt|mv|nano|nc|netstat|nice|nl|node|nohup|notify-send|npm|nslookup|op|open|parted|passwd|paste|pathchk|ping|pkill|pnpm|podman|podman-compose|popd|pr|printcap|printenv|ps|pushd|pv|quota|quotacheck|quotactl|ram|rar|rcp|reboot|remsync|rename|renice|rev|rm|rmdir|rpm|rsync|scp|screen|sdiff|sed|sendmail|seq|service|sftp|sh|shellcheck|shuf|shutdown|sleep|slocate|sort|split|ssh|stat|strace|su|sudo|sum|suspend|swapon|sync|sysctl|tac|tail|tar|tee|time|timeout|top|touch|tr|traceroute|tsort|tty|umount|uname|unexpand|uniq|units|unrar|unshar|unzip|update-grub|uptime|useradd|userdel|usermod|users|uudecode|uuencode|v|vcpkg|vdir|vi|vim|virsh|vmstat|wait|watch|wc|wget|whereis|which|who|whoami|write|xargs|xdg-open|yarn|yes|zenity|zip|zsh|zypper)(?=$|[)\s;|&])/,lookbehind:!0},keyword:{pattern:/(^|[\s;|&]|[<>]\()(?:case|do|done|elif|else|esac|fi|for|function|if|in|select|then|until|while)(?=$|[)\s;|&])/,lookbehind:!0},builtin:{pattern:/(^|[\s;|&]|[<>]\()(?:\.|:|alias|bind|break|builtin|caller|cd|command|continue|declare|echo|enable|eval|exec|exit|export|getopts|hash|help|let|local|logout|mapfile|printf|pwd|read|readarray|readonly|return|set|shift|shopt|source|test|times|trap|type|typeset|ulimit|umask|unalias|unset)(?=$|[)\s;|&])/,lookbehind:!0,alias:"class-name"},boolean:{pattern:/(^|[\s;|&]|[<>]\()(?:false|true)(?=$|[)\s;|&])/,lookbehind:!0},"file-descriptor":{pattern:/\B&\d\b/,alias:"important"},operator:{pattern:/\d?<>|>\||\+=|=[=~]?|!=?|<<[<-]?|[&\d]?>>|\d[<>]&?|[<>][&=]?|&[>&]?|\|[&|]?/,inside:{"file-descriptor":{pattern:/^\d/,alias:"important"}}},punctuation:/\$?\(\(?|\)\)?|\.\.|[{}[\];\\]/,number:{pattern:/(^|\s)(?:[1-9]\d*|0)(?:[.,]\d+)?\b/,lookbehind:!0}},a.inside=e.languages.bash;for(var s=["comment","function-name","for-or-select","assign-left","parameter","string","environment","function","keyword","builtin","boolean","file-descriptor","operator","punctuation","number"],o=n.variable[1].inside,i=0;i<s.length;i++)o[s[i]]=e.languages.bash[s[i]];e.languages.sh=e.languages.bash,e.languages.shell=e.languages.bash}(Prism);
Prism.languages.python={comment:{pattern:/(^|[^\\])#.*/,lookbehind:!0,greedy:!0},"string-interpolation":{pattern:/(?:f|fr|rf)(?:("""|''')[\s\S]*?\1|("|')(?:\\.|(?!\2)[^\\\r\n])*\2)/i,greedy:!0,inside:{interpolation:{pattern:/((?:^|[^{])(?:\{\{)*)\{(?!\{)(?:[^{}]|\{(?!\{)(?:[^{}]|\{(?!\{)(?:[^{}])+\})+\})+\}/,lookbehind:!0,inside:{"format-spec":{pattern:/(:)[^:(){}]+(?=\}$)/,lookbehind:!0},"conversion-option":{pattern:/![sra](?=[:}]$)/,alias:"punctuation"},rest:null}},string:/[\s\S]+/}},"triple-quoted-string":{pattern:/(?:[rub]|br|rb)?("""|''')[\s\S]*?\1/i,greedy:!0,alias:"string"},string:{pattern:/(?:[rub]|br|rb)?("|')(?:\\.|(?!\1)[^\\\r\n])*\1/i,greedy:!0},function:{pattern:/((?:^|\s)def[ \t]+)[a-zA-Z_]\w*(?=\s*\()/g,lookbehind:!0},"class-name":{pattern:/(\bclass\s+)\w+/i,lookbehind:!0},decorator:{pattern:/(^[\t ]*)@\w+(?:\.\w+)*/m,lookbehind:!0,alias:["annotation","punctuation"],inside:{punctuation:/\./}},keyword:/\b(?:_(?=\s*:)|and|as|assert|async|await|break|case|class|continue|def|del|elif|else|except|exec|finally|for|from|global|if|import|in|is|lambda|match|nonlocal|not|or|pass|print|raise|return|try|while|with|yield)\b/,builtin:/\b(?:__import__|abs|all|any|apply|ascii|basestring|bin|bool|buffer|bytearray|bytes|callable|chr|classmethod|cmp|coerce|compile|complex|delattr|dict|dir|divmod|enumerate|eval|execfile|file|filter|float|format|frozenset|getattr|globals|hasattr|hash|help|hex|id|input|int|intern|isinstance|issubclass|iter|len|list|locals|long|map|max|memoryview|min|next|object|oct|open|ord|pow|property|range|raw_input|reduce|reload|repr|reversed|round|set|setattr|slice|sorted|staticmethod|str|sum|super|tuple|type|unichr|unicode|vars|xrange|zip)\b/,boolean:/\b(?:False|None|True)\b/,number:/\b0(?:b(?:_?[01])+|o(?:_?[0-7])+|x(?:_?[a-f0-9])+)\b|(?:\b\d+(?:_\d+)*(?:\.(?:\d+(?:_\d+)*)?)?|\B\.\d+(?:_\d+)*)(?:e[+-]?\d+(?:_\d+)*)?j?(?!\w)/i,operator:/[-+%=]=?|!=|:=|\*\*?=?|\/\/?=?|<[<=>]?|>[=>]?|[&|^~]/,punctuation:/[{}[\];(),.:]/},Prism.languages.python["string-interpolation"].inside.interpolation.inside.rest=Prism.languages.python,Prism.languages.py=Prism.languages.python;


================================================
FILE: docs/config.py
================================================
import os

BASE_URLS = {
    "local": "http://localhost:8000/",
    "production": "https://jamesg.blog/aurora/",
}

SITE_ENV = os.environ.get("SITE_ENV", "local")
BASE_URL = BASE_URLS[SITE_ENV]
ROOT_DIR = "pages"
LAYOUTS_BASE_DIR = "_layouts"
SITE_DIR = "_site"
HOOKS = {
    "post_template_generation": {"highlighting": ["highlight_code"]},
    "pre_template_generation": {"highlighting": ["generate_table_of_contents"]},
}
SITE_STATE = {}


================================================
FILE: docs/highlighting.py
================================================
from bs4 import BeautifulSoup
from pygments import highlight
from pygments.formatters import HtmlFormatter
from pygments.lexers import CssLexer, HtmlLexer, PythonLexer

languages = {
    "python": PythonLexer(),
    "html": HtmlLexer(),
    "text": HtmlLexer(),
    "css": CssLexer(),
}


def highlight_code(file_name, page_state, _, page_contents):
    if ".txt" in file_name or ".xml" in file_name or "styles.html" in file_name:
        return page_contents

    soup = BeautifulSoup(page_contents, "lxml")

    for pre in soup.find_all("pre"):
        code = pre.find("code")
        try:
            language = code["class"][0].split("language-")[1]
            code = highlight(code.text, languages[language], HtmlFormatter())
        except:
            continue

        pre.replace_with(BeautifulSoup(code, "html.parser"))

    if soup.find("article", {"class": "post"}):
        # add id to all h2s, h3s, etc.
        for h2 in soup.find_all(["h2", "h3", "h4", "h5", "h6"]):
            h2["id"] = h2.text.lower().replace(" ", "-")

        # surround h2 with a link
        for h2 in soup.find_all(["h2", "h3", "h4", "h5", "h6"]):
            link = soup.new_tag("a", href=f"#{h2['id']}")
            link.string = h2.text  # Set the link text to the h2's current text
            h2.clear()
            h2.append(link)

    # for each "sup", add class=f-1
    for i, sup in enumerate(soup.find_all("sup")):
        sup["id"] = f"f-{i+1}"

    # get all footnote-definition and add [↩] link to end
    for footnote in soup.find_all("div", {"class": "footnote-definition"}):
        link = soup.new_tag("a", href=f"#f-{footnote['id']}")
        link.string = "[↩]"
        footnote.append(link)

    css = HtmlFormatter().get_style_defs(".highlight")
    css = f"<style>{css}</style>"

    # this happens for bookmarks
    if not soup.find("body"):
        return ""

    body = soup.find("body")
    body.insert(0, BeautifulSoup(css, "html.parser"))

    return str(soup)


def generate_table_of_contents(file_name, page_state, site_state):
    page = BeautifulSoup(page_state["page"].contents, "html.parser")
    h2s = page.find_all("h2")
    toc = []
    for h2 in h2s:
        toc.append(
            {"text": h2.text, "id": h2.text.lower().replace(" ", "-"), "children": []}
        )
        h3s = h2.find_next_siblings("h3")
        for h3 in h3s:
            # if h3 is a child of another h3, skip it
            if h3.find_previous_sibling("h2") != h2:
                continue
            toc[-1]["children"].append(
                {
                    "text": h3.text,
                    "id": h3.text.lower().replace(" ", "-"),
                }
            )
    page_state["page"].toc = toc

    return page_state


================================================
FILE: docs/pages/_layouts/default.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">

    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>{{ page.title }} | Aurora User Manual</title>

    <meta name="description" content="Aurora: An extensible, Python-based static site generator." />
    <meta name="author" content="Aurora" />
    <meta name="keywords" content="Aurora, static site generator, Python" />

    <meta property="og:image" content="https://screenshots.jamesg.blog?url={{ page.url }}" />
    <meta property="og:title" content="{{ page.title }} - Aurora Documentation" />
    <meta property="og:description" content="Aurora: An extensible, Python-based static site generator." />
    <meta property="og:url" content="{{ page.url }}" />

    <link rel="icon" href="{{ site.root_url }}/assets/aurora-logo.png" type="image/x-icon" />

    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
        href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap"
        rel="stylesheet"
    />
    <link href="{{ site.root_url }}/assets/prism.css" rel="stylesheet" />
    <script src="{{ site.root_url }}/assets/prism.js"></script>

    <link href="https://jamesg.blog/assets/mascot.svg" rel="icon">

    <style>
        :root {  
          --light-background-color: #f7f7f7;
          --light-foreground-color: black;
          --light-border-color: lightgrey;
          --primary-color: #aaaaff;
          --border-radius: var(--medium-space);
          --dark-background-color: #242424;
          --dark-code-color: #333345;
          --dark-foreground-color: #d7d7d7;
          --light-focus-color: rgb(255, 225, 116);
          --dark-focus-color: #1e3cb1;
          --dark-border-color: #747474;
          --small-space: 0.1rem;
          --medium-space: 0.5rem;
          --large-space: 1rem;
        }
        html {
            color-scheme: light dark;
        }
        .search {
            list-style-type: none;
            padding-left: 0;
        }
        .search li {
            padding-left: 1em;
            padding-right: 1em;
            padding-top: 0.5em;
            padding-bottom: 0.5em;
            background-color: light-dark(white, var(--dark-background-color));;
            box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.1);
        }
        .search li:hover {
            background-color: rgba(0, 0, 0, 0.05);
        }
        .callout {
            background-color: rgba(65, 105, 225, 0.198);
            border-left: 3px solid #aaaaff;
            padding: 1em;
        }
        .warning {
            background-color: rgba(255, 165, 0, 0.198);
            border-left: 3px solid orange;
            padding: 1em;
        }
        @font-face {
            font-family: "Standard";
            src: url("standard-book-webfont.woff2");
            font-display: swap;
        }
        html {
            background-color: #f9f9f9;
            font-family: "Standard", sans-serif;
            padding: 0;
            margin: 0;
            box-sizing: border-box;
            border-top: 5px solid #aaaaff;
            position: fixed;
            width: 100%;
        }
        h1, h2 {
            margin-bottom: 0.25em;
            padding-bottom: 0;
        }
        h2 {
            margin-top: 1.25em;
        }
        h1 + p, h2 + p {
            margin-top: 0;
            padding-top: 0;
        }
        body {
            padding: 0;
            margin: 0;
        }
        * {
            line-height: 1.5;
            color: light-dark(black, var(--dark-foreground-color));
        }
        #main {
            display: grid;
            grid-template-columns: 1fr 4fr;
            background-color: light-dark(white, var(--dark-background-color));
        }
        article {
            padding-bottom: 3em;
        }
        h3 {
            text-transform: uppercase;
            font-size: 0.9em;
            margin-bottom: 0.25em;
            margin-top: 1.5em;
        }
        aside {
            border-right: 0.5px solid light-dark(rgb(57, 57, 57), var(--dark-border-color));
            padding-right: 1.5em;
        }
        .right-sidebar {
            position: sticky;
            top: 1em;
            padding-top: 1em;
        }
        .toc h3 {
            margin: 0;
        }
        .pages ul {
            list-style-type: none;
            box-sizing: border-box;
            max-width: 100%;
            padding-left: 1em;
        }
        .pages li {
            list-style-type: none;
            padding: 0.5em 0;
            /*! border-radius: 0.5em; */
        }
        .toc ul {
            list-style-type: none;
            padding-left: 1em;
            margin-top: 0;
        }
        .toc a:hover {
            color: rgb(26, 46, 109);
        }
        .pages a, pre {
            display: block;
            background-color: light-dark(white, var(--dark-background-color));;
            box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.1);
            padding-left: 1em;
            padding-right: 1em;
        }
        pre {
            padding: 1em;
            text-wrap: stable;
            border: 0.1em solid light-dark(rgb(57, 57, 57), var(--dark-border-color));
        }
        nav {
            background-color: light-dark(white, var(--dark-background-color));;
            padding: 0.5em;
            /*! padding-left: 2.5em; */
            border-bottom: 0.5px solid light-dark(rgb(57, 57, 57), var(--dark-border-color));
        }
        .logo-head {
            display: flex;align-content: center;align-items: center;margin-left: 0.5em;
        }
        nav h1 {
            font-size: 2em;
            padding: 0;
            margin: 0;
        }
        ul a {
            color: black;
            text-decoration: none;
        }
        article a {
            color: #aaaaff;
            text-decoration: none;
        }
        ul a:hover {
            background-color: rgba(0, 0, 0, 0.05);
        }
        .focused {
            border-left: 3px solid #aaaaff;
            padding-left: 0.5em;
            text-decoration: none;
            font-weight: bold;
        }
        p code {
            background-color: light-dark(#f1f1f1, var(--dark-code-color));
        }
        body {
            height: 100vh;
        }
        aside, main {
            height: calc(100vh - 4em);
            overflow-y: auto;
        }
        body {
            scrollbar-width: none;
        }
        h1 {
            margin-top: 0;
        }
        main {
            display: grid;
            gap: 1em;
            grid-template-columns: 8fr 2fr;
        }
        main aside {
            position: sticky;
            /*! top: 1em; */
            padding-top: 1em;
        }
        main aside h2 {
            font-size: 1.25em;
        }
        main aside h3 {
            font-size: 1em;
            text-transform: none;
            font-weight: normal;
        }
        nav {
            display: grid;
            list-style-type: none;
            /*! padding: 1em; */
            /*! padding-left: 2.5em; */
            /*! padding-right: 2em; */
            /*! margin: 0; */
            grid-template-columns: 1fr 4fr;
        }
        nav ul {
            display: flex;
            list-style-type: none;
            padding: 0;
            margin: 0;
        }
        nav ul li {
            margin-right: 1em;
        }
        article li {
            margin-bottom: 1em;
        }
        .toc {
            border-left: 0.5px solid light-dark(rgb(57, 57, 57), var(--dark-border-color));
            padding-left: 1em;
        }
        h1 {
            font-size: 1.5em;
        }
        .subtitle {
            font-size: 1em;
        }
        .pre-inner {
            padding: 1em;
        }
        .code-head {
            background-color: #f1f1f1;
            padding: 0.5em;
            padding-left: 1em;
            border-bottom: 0.5px solid light-dark(rgb(57, 57, 57), var(--dark-border-color));
        }
        th {
            text-align: left;
        }
        table {
            border-collapse: collapse;
            width: 100%;
            border-spacing: 1em;
            background: light-dark(white, var(--dark-background-color));;
            box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.1);
        }
        td, th {
            padding: 0.5em;
        }
        tr:first-child {
            margin-right: 1em;
        }
        tr {
            border-bottom: 0.5px solid light-dark(rgb(57, 57, 57), var(--dark-border-color));
        }
        input {
            padding: 0.5em;
            border: 0.5px solid light-dark(rgb(57, 57, 57), var(--dark-border-color));
            width: 100%;
            box-sizing: border-box;
            background-color: rgba(0, 0, 0, 0.05);
        }

        .nav-title {
            padding-left: 1.5em;
        }
        article {
            padding-left: 2em;
            padding-right: 2em;
        }
        .manual {
            display: block;margin: 0;padding: 0;
        }
        nav a {
            text-decoration: none;
        }
        .right-sidebar a {
            text-decoration: none;
            color: #aaaaff;
        }
        .right-sidebar svg {
            display: inline;
            height: 1em;
            color: #aaaaff;
        }
        .menu {
            background-color: light-dark(white, var(--dark-background-color));;
            border-bottom: 0.5px solid light-dark(rgb(57, 57, 57), var(--dark-border-color));
        }
        .menu {
            display: grid;
            grid-template-columns: 1fr 1fr;
        }
        .jcb:hover {
            background-color: #aaaaff;
            a {
                color: light-dark(white, var(--dark-background-color));;
            }
        }
        .aurora:hover {
            background-color: #aaaaff;
        }
        .menu ul {
            display: flex;
            justify-content: flex-end;
            list-style-type: none;
            padding: 0;
            margin: 0;
        }
        .menu li {
            padding-top: 0.25em;
        }
        .menu ul li {
            padding-left: 1em;
            padding-right: 1em;
        }
        .link-with-logo {
            display: flex;
            align-items: center;
            margin-left: 1em;
        }
        .link-with-logo svg {
            margin-right: 0.25em;
        }
        .nav-title svg {
            display: none;
        }
        .toc a, .links a {
            color: light-dark(rgb(81, 81, 81), var(--dark-foreground-color));
        }
        video {
            max-width: 100%;
        }
        @media (max-width: 800px) {
            #main {
                grid-template-columns: 1fr;
            }
            aside {
                display: none;
            }
            main {
                grid-template-columns: 1fr;
            }
            article {
                max-width: 100%;
                padding: 1em;
            }
            .nav-title svg {
                display: block;
                margin-left: auto;
                padding-right: 0.5em;
                max-height: 1.5em;
            }
            aside li svg {
                display: inline-block;
                width: 1em;
                height: 1em;
                margin-right: 0.5em;
                align-self: center;
            }
            nav {
                flex-direction: column;
                align-items: center;
            }
            nav ul {
                display: block;
            }
            nav ul li {
                margin-right: 0;
                margin-bottom: 0.5em;
            }
            .logo-head {
                display: block;
            }
            h1 {
                font-size: 1.25em;
                margin-left: 0;
            }
            .manual {
                margin-left: 1em;
            }
            article {
                padding-left: 1em;
            }
            nav {
                display: block;
                padding: 0;
                background-color: light-dark(white, var(--dark-background-color));;
            }
            #title {
                font-size: 1em;
            }
            .nav-title {
                padding-left: 0;
            }
            nav img {
                display: none;
            }
            .nav-title {
                background-color: light-dark(white, var(--dark-background-color));;
                box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.1);
                padding: 0.5em;
                padding-left: 1em;
                display: flex;
                align-items: center;
                justify-content: space-between;
            }
            .nav-title:hover {
                background-color: light-dark(#f7f7f7, #333345);
                cursor: pointer;
            }
            html {
                border-top-width: 5px;
            }
            .logo-head {
                background-color: light-dark(rgb(238, 237, 237), #333345);
                color: light-dark(white, var(--dark-background-color));;
                display: flex;
                align-items: center;
                padding: 0.5em;
                padding-left: 1em;
                margin: 0;
            }
            .logo-head div {
                display: flex;
                align-items: center;
            }
            table {
                overflow-x: auto;
                overflow-y: scroll;
            }
            .menu {
                display: none;
            }
        }
        .toc h3 {
            margin-bottom: 0.25em;
        }
        footer {
            display: grid;
            grid-template-columns: 1fr 1fr;
            margin-top: 2em;
            background-color: light-dark(white, var(--dark-background-color));;
            box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.1);
            padding-left: 1em;
            padding-right: 1em;
            gap: 1em;
        }
        footer div:first-child {
            border-right: 0.5px solid light-dark(rgb(57, 57, 57), var(--dark-border-color));
        }
        footer div:last-child {
            display: flex;
            justify-content: flex-end;
        }
        .pages ul {
            padding-bottom: 3em;
        }
        .links {
            margin-top: 3em;
            border-left: 0.5px solid light-dark(rgb(57, 57, 57), var(--dark-border-color));
        }
        form {
            display: flex;
            align-items: center;
            padding-left: 0;
            padding-right: 0;
        }
        form svg {
            margin-left: 0.5em;
            height: 1.5em;
        }
        form button {
            display: flex;
            align-items: center;
            background-color: inherit;
            border: none;
            outline: none;
            cursor: pointer;
        }
        .search h2 {
            margin-top: 0.5em;
        }
        .pages a:has(.current) {
            border-left: 3px solid #aaaaff;
            text-decoration: none;
            font-weight: bold;
        }
        img {
            max-width: 100%;
        }
        #template-grid, #user-grid {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
            gap: 1em;
            list-style: none;
            padding: 0;
            margin: 0;
        }
        #template-grid img, #user-grid img {
            height: 100%;
            object-fit: cover;
            object-position: 0 0;
        }
    </style>
    <script>
        document.addEventListener("DOMContentLoaded", function() {
            var focused = document.querySelector(".focused");
            if (focused) {
                focused.scrollIntoView({block: "center", inline: "nearest"});
            }
        });
    </script>
</head>
<script>
    document.addEventListener("DOMContentLoaded", function() {
        document.querySelector(".nav-title").addEventListener("click", function() {
            if (!document.querySelector(".pages").style.display || document.querySelector(".pages").style.display === "none") {
                document.querySelector(".pages").style.display = "block";
            } else {
                document.querySelector(".pages").style.display = "none";
            }
        });
        // scroll sidebar to focused link, with 1em padding
        var focused = document.querySelector(".focused");
        if (focused) {
            focused.scrollIntoView({block: "center", inline: "nearest"});
        }
    });
</script>
<body>
    <div class="menu"><div></div><ul><a href="https://jamesg.blog/" class="jcb"><li>James' Coffee Blog</li></a><a href="https://aurora.jamesg.blog/" class="aurora"><li style="background-color: #aaaaff; color: light-dark(white, var(--dark-background-color));">Aurora</li></a><a href="https://jamesg.blog/jamesql/"><li>JameSQL</li></a></ul></div>
    <nav><div class="logo-head"><img src="https://jamesg.blog/assets/mascot.svg" style="height: 2.5em;/*! margin-top: 1em; */margin-right: 1em;/*! float: right; */"><div style="/*! display: flex; *//*! padding-top: 1em; */padding-bottom: 0;/*! border-bottom: 1px solid black; */"><h1 style="/*! display: inline-block; */font-size: 1em;margin: 0;">Aurora</h1>
    <p class="manual">User Manual</p></div></div><a href="#"><div class="nav-title"><h1 id="title">{{ page.title }}</h1> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><circle cx="128" cy="128" r="96" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="88" y1="128" x2="168" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="136 96 168 128 136 160" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg></div></a><span></span></nav>
    <div id="main">
        <aside class="pages">
            <ul>
                <h3>Get Started</h3>
                <a href="{{ site.root_url }}"
                    ><li {% if page.permalink.strip() == "{{ site.root_url.strip() }}" %}class='current'{% endif %}>What is Aurora?</li></a
                >
                <a href="{{ site.root_url }}start/"><li {% if page.permalink == "/start/" %}class='current'{% endif %}>Start a Website</li></a>
                <a href="{{ site.root_url }}blog/"><li {% if page.permalink == "/blog/" %}class='current'{% endif %}>Use Aurora as a Blog</li></a>
                <a href="{{ site.root_url }}structure/"><li {% if page.permalink == "/structure/" %}class='current'{% endif %}>Site Structure</li></a>
                <a href="{{ site.root_url }}configuration/"><li {% if page.permalink == "/configuration/" %}class='current'{% endif %}>Configuration</li></a>
                <a href="{{ site.root_url }}build-methods/"><li {% if page.permalink == "/build-methods/" %}class='current'{% endif %}>Build Methods</li></a>
                <a href="{{ site.root_url }}templating/"><li {% if page.permalink == "/templating/" %}class='current'{% endif %}>Templating with Jinja2</li></a>
                <a href="{{ site.root_url }}archives/"><li {% if page.permalink == "/archives/" %}class='current'{% endif %}>Date, Category, and Tag Archives</li></a>
                <h3>Build Sites with Data</h3>
                <a href="{{ site.root_url }}collections/"><li {% if page.permalink == "/collections/" %}class='current'{% endif %}>Collections</li></a>
                <a href="{{ site.root_url }}pagination/"><li {% if page.permalink == "/pagination/" %}class='current'{% endif %}>Pagination</li></a>
                <h3>Advanced Features</h3>
                <a href="{{ site.root_url }}state/"><li {% if page.permalink == "/state/" %}class='current'{% endif %}>State</li></a>
                <a href="{{ site.root_url }}hooks/"><li {% if page.permalink == "/hooks/" %}class='current'{% endif %}>Hooks</li></a>
                <a href="{{ site.root_url }}dates/"><li {% if page.permalink == "/dates/" %}class='current'{% endif %}>Date Handling</li></a>
                <a href="{{ site.root_url }}permalinks/"><li {% if page.permalink == "/permalinks/" %}class='current'{% endif %}>Permalinks</li></a>
                <a href="{{ site.root_url }}sitemap/"><li {% if page.permalink == "/sitemap/" %}class='current'{% endif %}>Sitemap</li></a>
                <a href="{{ site.root_url }}robots/"><li {% if page.permalink == "/robots/" %}class='current'{% endif %}>robots.txt</li></a>
                <h3>Demos</h3>
                <a href="{{ site.root_url }}users/"><li {% if page.permalink == "/users/" %}class='current'{% endif %}>Sites Built with Aurora</li></a>
                <a href="{{ site.root_url }}templates/"><li {% if page.permalink == "/templates/" %}class='current'{% endif %}>Aurora Templates</li></a>
                <a href="{{ site.root_url }}performance/"><li {% if page.permalink == "/performance/" %}class='current'{% endif %}>Performance</li></a>
                <a href="{{ site.root_url }}design/"><li {% if page.permalink == "/design/" %}class='current'{% endif %}>Aurora Design</li></a>
            </ul>
        </aside>
        <main>
            <article>
                {{ content }}
            </article>
            {% if not site.page.notoc %}
                <aside class="right-sidebar">
                    {% if site.page.toc %}
                    <div class="toc">
                        <h2>Contents</h2>
                        {% for item in site.page.toc %}
                            <h3><a href="#{{ item.id }}">{{ item.text }}</a></h3>
                            {% if item.children %}
                                <ul>
                                    {% for child in item.children %}
                                        <li><a href="#{{ child.id }}">{{ child.text }}</a></li>
                                    {% endfor %}
                                </ul>
                            {% endif %}
                        {% endfor %}
                    </div>
                    {% endif %}
                    <p class="links"><a href="https://github.com/capjamesg/aurora/tree/main/docs/{{ page.generated_from }}" class="link-with-logo"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M92.69,216H48a8,8,0,0,1-8-8V163.31a8,8,0,0,1,2.34-5.65L165.66,34.34a8,8,0,0,1,11.31,0L221.66,79a8,8,0,0,1,0,11.31L98.34,213.66A8,8,0,0,1,92.69,216Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="136" y1="64" x2="192" y2="120" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="164" y1="92" x2="68" y2="188" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="95.49" y1="215.49" x2="40.51" y2="160.51" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>Edit this Page</a></p>
                </aside>
            {% endif %}
        </main>
    </div>
</body>
</html>


================================================
FILE: docs/pages/templates/404.html
================================================
---
title: 404
permalink: /404.html
layout: default
---

<p>This page does not exist. <a href="/">Go back to the homepage.</a></p>


================================================
FILE: docs/pages/templates/archives.md
================================================
---
title: Date, Category, and Tag Archives
layout: default
permalink: /archives/
---

Aurora has support built-in for generating date, category, and tag archives. These are useful for blogs.

## Date Archives

Aurora automatically generates date archives for blog posts. You do not need to configure any setting to use this feature.

Date archives are generated as follows:

- `https://example.com/2024/`: All posts published in 2024.
- `https://example.com/2024/01/`: All posts published in January 2024.
- `https://example.com/2024/01/01/`: All posts published on January 1, 2024.

## Category and Tag Archives

Aurora automatically generates category and tag archives.

These archives are generated if you specify `category` and/or `tag` attributes in your blog post front matters.

Category archives are generated as follows:

- `https://example.com/category/<name>`: All posts with the specified category.

Tag archives are generated as follows:

- `https://example.com/tag/<name>`: All posts with the specified tag.

### Customize Category and Tag Paths

You can change the default category and tag path roots.

To do so, update the `SITE_STATE` value in your config.py configuration to include:

<pre><code class="language-python">SITE_STATE = {
    "category_slug_root": "categories",
    "tag_slug_root": "tags",
}</code></pre>

The above example would change the category and tag paths to:

- `https://example.com/categories/<name>`: All posts with the specified category.
- `https://example.com/tags/<name>`: All posts with the specified tag.

================================================
FILE: docs/pages/templates/blog.html
================================================
---
title: Use Aurora as a Blog
permalink: /blog/
layout: default
---

<p>Aurora has out-of-the-box features designed for use with blogs.</p>

<h2>Write a Blog Post</h2>

<p>To use Aurora as a blog, create a markdown file with the following name structure in your <code>pages/posts</code> directory:</p>

<pre><code class="language-text">YYYY-MM-DD-title.md</code></pre>

<p>For example, <code>2020-01-01-hello-world.md</code>.</p>

<p>Within this file, you can specify front matter and, optionally, jinja2 templating.</p>

<p>Here is an example:</p>

<pre><code class="language-html">---
title: Hello, World!
layout: post
---

This is our first blog post.
</code></pre>

<p>This will be rendered as a blog post using the "pages/_layouts/post.html" file.</p>

<p>Aurora automatically turns your permalink into a URL. For example, the above file will be available at <code>https://example.com/2020/01/01/hello-world/</code>.</p>

<h2>Categories and Tags</h2>

<p>Aurora can automatically generate archive pages for categories and tags.</p>

<p>Categories and tags are treated as two separate collections.</p>

<p>To use this feature, you need to give at least one blog post a category or a tag, like so:</p>

<pre><code class="language-html">---
title: Hello, World!
layout: post
categories:
  - Announcement
---

This is our first blog post.
</code></pre>

<p>Categories and tags are not case-sensitive.</p>

<p>You then need to specify either a category or tag layout file, depending on which you want to support.</p>

<p>To do so, create a file called <code>category.html</code> or <code>tag.html</code> in your <code>pages/_layouts</code> directory.</p>

<p>This file will have access to a <code>page.posts</code> variable that lists all posts in the category or tag.</p>

<p>Here is an example of a category layout file:</p>

{% raw %}
<pre><code class="language-html">
---
layout: default
title: &quot;Category Archive&quot;
---

&lt;ul&gt;
    {% for post in page.posts %}
        &lt;li&gt;
            &lt;a href=&quot;{{ post.url }}&quot;&gt;{{ post.title }}&lt;/a&gt;
        &lt;/li&gt;
    {% endfor %}
&lt;/ul&gt;
</code></pre>
{% endraw %}

<p>In this example, a page at <code>/category/announcement/index.html</code> will be generated.</p>

<h2>Date Archives</h2>

<p>Aurora can automatically generate date pages for categories and tags.</p>

<p>Date archives show all posts published on a specific day.</p>

<p><em>There is not currently support for month-based or year-based archives.</em></p>

<p>To use this feature, you need to have at least one blog post.</p>

<p>You then need to specify a date archive layout.</p>

<p>To do so, create a file called <code>date.html</code> in your <code>pages/_layouts</code> directory.</p>

<p>This file will have access to a <code>page.posts</code> variable that lists all posts published on a specific day.</p>

<p>Here is an example of a category layout file:</p>

{% raw %}
<pre><code class="language-html">
---
layout: default
title: &quot;Date Archive&quot;
---

&lt;ul&gt;
    {% for post in page.posts %}
        &lt;li&gt;
            &lt;a href=&quot;{{ post.url }}&quot;&gt;{{ post.title }}&lt;/a&gt;
        &lt;/li&gt;
    {% endfor %}
&lt;/ul&gt;
</code></pre>
{% endraw %}

<p>In this example, a page at <code>/2024/01/01/index.html</code> will be generated, which lists one entry: the hello world post we wrote earlier in this guide.</p>

<p>Now you have a blog set up with Aurora! 🎉</p>


================================================
FILE: docs/pages/templates/build-methods.md
================================================
---
title: Build Methods
permalink: /build-methods/
layout: default
---

There are three ways you can build your Aurora site:

1. Full build
2. Incremental build
3. Interactive, incremental build

## Full Build

A full build generates your entire website.

Your site is saved in `_site`, ready for serving.

To build your site, navigate to the root directory of your project (the folder with the `config.py` file in it), and run:

<pre><code class="language-bash">aurora build</code></pre>

Your site will be saved in and ready to serve from the `_site` directory.

## Incremental Build

An incremental build generates only the files that have changed since the last build. This is faster than a full build.

If you have not fully built your site before, the incremental build will fully build your site first. Then, subsequent runs will only build the files that have changed since the last build.

For example, suppose you have 1,000 pages on your site. You have already built your site, and now you change one file. With the incremental build, option, only the page you changed -- and its dependencies -- will be regenerated.

Incremental builds are designed to speed up the build process, particularly for large sites with thousands or tens of thousands of pages.

To run an incremental build, navigate to the root directory of your project and run:

<pre><code class="language-bash">aurora build --incremental</code></pre>

<p class="callout-tip"><b>Tip</b>: Incremental builds support CSV and JSON data files.</p>

## Interactive, Incremental Build

An interactive, incremental build generates your full site. It starts a web server through which you can preview pages. When you make a change to any file, the changed file -- and its dependencies -- are re-built and made available over the server. Any open browser tabs that are viewing the site will automatically refresh to show the changes.

This mode is intended for development. With interactive, incremental building, you can see changes to your site as you make them, without having to wait for your full site to build, and without having to manually refresh your browser.

To run an interactive, incremental build, navigate to the root directory of your project and run:

<pre><code class="language-bash">aurora serve</code></pre>

A server will start on `http://localhost:8000`. Open this URL in your browser to view your site.

<p class="callout-note"><b>Note</b>: The interactive server should not be used in production.</p>


================================================
FILE: docs/pages/templates/collections-from-data.html
================================================
---
title: Create a Collection from Data
permalink: /collections-from-data/
layout: default
---

<p>You can turn data from JSON and CSV files into web pages.</p>
<p>
    This is useful if you have a data set that you want to turn into a website.
</p>
<p>
    For example, you could export a list of coffee shops you have visited from a
    spreadsheet and turn the list into a static website.
</p>
<h2>Create a Collection</h2>
<h3>JSON</h3>
<p>
    To create a collection from a JSON file, add a new file to your site&#39;s
    <code>pages/_data</code> directory. This file should have a
    <code>.json</code> extension.
</p>
<p>Within the file, create a list that contains JSON objects, like this:</p>
<pre><code class="language-python">[
    {
        "slug": "rosslyn-coffee",
        "layout": "coffee",
        "title": "Rosslyn Coffee in London is terrific."
    }
]
</code></pre>
<p>
    This file is called
    <code>pages/_data/coffee.json</code>.
</p>
<p>
    Every entry <b>must</b> have a <code>layout</code> key. This corresponds
    with the name of the template that will be used to render the page. For
    example, the <code>coffee</code> layout will be rendered using the
    <code>pages/_layouts/coffee.html</code> template.
</p>
<p>
    Every entry <b>must</b> also have a <code>slug</code> key. This corresponds
    with the name of the page that will be generated. In the case above, one
    file will be created in the <code>_site</code> output directory:
    <code>_site/coffee/rosslyn-coffee/index.html</code>.
</p>
<h3>CSV</h3>
<p>
    To create a collection from a CSV file, add a new file to your site&#39;s
    <code>pages/_data</code> directory. This file should have a
    <code>.json</code> extension.
</p>
<p>Here is an example CSV file:</p>
<pre><code class="language-python">slug,layout,title
rosslyn-coffee,coffee,Rosslyn Coffee in London is terrific.
</code></pre>
<p class="callout">
    Your CSV file must have a header row that contains the keys for each entry.
</p>
<p>
    This file is called
    <code>pages/_data/coffee.csv</code>.
</p>
<p>
    Every entry <b>must</b> have a <code>layout</code> key. This corresponds
    with the name of the template that will be used to render the page. For
    example, the <code>coffee</code> layout will be rendered using the
    <code>pages/_layouts/coffee.html</code> template.
</p>
<p>
    Every entry <b>must</b> also have a <code>slug</code> key. This corresponds
    with the name of the page that will be generated. In the case above, one
    file will be created in the <code>_site</code> output directory:
    <code>_site/coffee/rosslyn-coffee/index.html</code>.
</p>


================================================
FILE: docs/pages/templates/collections.md
================================================
---
title: Data Collections
permalink: /collections/
layout: default
---

Data collections are groups of data on a website.

You can use collections to create lists of content items (i.e. all of the bookmarks on your website).

You can create a data collection by:

1. Loading data from a JSON file
2. Loading data from a CSV file
3. Specifying a `collections` value on any page on your website

<h2>Create a Collection</h2>
<h3>JSON</h3>
<p>
    To create a collection from a JSON file, add a new file to your site&#39;s
    <code>pages/_data</code> directory. This file should have a
    <code>.json</code> extension.
</p>
<p>Within the file, create a list that contains JSON objects, like this:</p>
<pre><code class="language-python">[
    {
        "slug": "rosslyn-coffee",
        "layout": "coffee",
        "title": "Rosslyn Coffee in London is terrific."
    }
]
</code></pre>
<p>
    This file is called
    <code>pages/_data/coffee.json</code>.
</p>
<p>
    Every entry <b>must</b> have a <code>layout</code> key. This corresponds
    with the name of the template that will be used to render the page. For
    example, the <code>coffee</code> layout will be rendered using the
    <code>pages/_layouts/coffee.html</code> template.
</p>
<p>
    Every entry <b>must</b> also have a <code>slug</code> key. This corresponds
    with the name of the page that will be generated. In the case above, one
    file will be created in the <code>_site</code> output directory:
    <code>_site/coffee/rosslyn-coffee/index.html</code>.
</p>
<h3>CSV</h3>
<p>
    To create a collection from a CSV file, add a new file to your site&#39;s
    <code>pages/_data</code> directory. This file should have a
    <code>.csv</code> extension.
</p>
<p>Here is an example CSV file:</p>
<pre><code class="language-python">slug,layout,title
rosslyn-coffee,coffee,Rosslyn Coffee in London is terrific.
</code></pre>
<p class="callout">
    Your CSV file must have a header row that contains the keys for each entry.
</p>
<p>
    This file is called
    <code>pages/_data/coffee.csv</code>.
</p>
<p>
    Every entry <b>must</b> have a <code>layout</code> key. This corresponds
    with the name of the template that will be used to render the page. For
    example, the <code>coffee</code> layout will be rendered using the
    <code>pages/_layouts/coffee.html</code> template.
</p>
<p>
    Every entry <b>must</b> also have a <code>slug</code> key. This corresponds
    with the name of the page that will be generated. In the case above, one
    file will be created in the <code>_site</code> output directory:
    <code>_site/coffee/rosslyn-coffee/index.html</code>.
</p>

# Specify a Collections Attribute

If you want to group multiple existing files together, you can specify a `collections` attribute on any page on your website.

To do so, use the following syntax:

<pre><code class="language-python">---
title: My Page
collections: coffee
---</code></pre>

You can then access the collection like so:

<pre><code class="language-python">{% raw %}{% for item in coffee %}{% endraw %}
    {{ item.title }}
{% raw %}{% endfor %}{% endraw %}</code></pre>


================================================
FILE: docs/pages/templates/configuration.html
================================================
---
title: Configure Your Website
layout: default
---

<p>
    You need a <code>config.py</code> file in the directory in which you will
    build your Aurora site. This file is automatically generated when you run
    <code>aurora new [site-name]</code>.
</p>
<p>
    This configuration file defines a few values that Aurora will use when
    processing your website.
</p>
<p>
    Here is the default <code>config.py</code> file, with accompanying comments:
</p>
<pre><code class="language-python">import os

BASE_URLS = {
    "local": os.getcwd(),
}

SITE_ENV = os.environ.get("SITE_ENV", "local")
BASE_URL = BASE_URLS[SITE_ENV]
ROOT_DIR = "pages" # where your site pages are
LAYOUTS_BASE_DIR = "_layouts" # where your site layouts are stored
SITE_DIR = "_site" # the directory in which your site will be saved
HOOKS = {} # used to register hooks (see Hooks documentation for details)
SITE_STATE = {}</code></pre>

<h2>Base URLs</h2>
<p>
    The <code>BASE_URLS</code> dictionary is used to define the base URL for
    your site. This is useful if you want to maintain multiple environments for
    your site (e.g., local, staging, production).
</p>
<p>
    Here is an example configuration of a site that has a local and staging
    environment:
</p>
<pre><code class="language-python"><span class="hljs-keyword">BASE_URLS </span>= {
    <span class="hljs-string">"production"</span>: <span class="hljs-string">"https://jamesg.blog"</span>,
    <span class="hljs-string">"staging"</span>: <span class="hljs-string">"https://staging.jamesg.blog"</span>,
    <span class="hljs-string">"local"</span>: os.getcwd(),
}
</code></pre>


<h2>See Also</h2>

<ul>
    <li><a href="/state/">State</a></li>
    <li><a href="/hooks/">Hooks</a></li>
</ul>


================================================
FILE: docs/pages/templates/dates.html
================================================
---
layout: default
title: Date Handling
permalink: /dates/
---

<p>Aurora has several default filters that you can use to handle dates.</p>

<ul>
    <li>long_date - Converts a date to a long format, e.g. "January 01, 2020"</li>
    <li>date_to_xml_string - Converts a date to an XML string, e.g. "2020-01-01T00:00:00"</li>
    <li>archive_date - Converts a date to a format suitable for archivess, e.g. "2020/01"</li>
    <li>month_number_to_written_month - Converts a month number to a written month, e.g. "01" to "January"</li>
</ul>

<p>These filters can be used like:</p>

<pre><code class="language-python">{% raw %}{{ date | long_date }}
{{ date | date_to_xml_string }}
{{ date | archive_date }}
{{ date | month_number_to_written_month }}{% endraw %}</code></pre>


================================================
FILE: docs/pages/templates/design.html
================================================
---
title: Aurora Design
permalink: /design/
layout: default
---

<p>I have written several blog posts that explore the design, inspiration, and development of Aurora.</p>

<p>See a list of these posts below.</p>

<ul>
    <li><a href="https://jamesg.blog/2024/06/18/aurora/">Announcing Aurora</a></li>
    <li><a href="https://jamesg.blog/2024/06/16/aurora-isr/">Implementing Incremental Static Regeneration in Aurora </a></li>
    <li><a href="https://jamesg.blog/2024/05/27/designing-aurora/">Designing Aurora, a new static site generator </a></li>
</ul>


================================================
FILE: docs/pages/templates/hooks.html
================================================
---
title: Hooks
layout: default
permalink: /hooks/
---

<p>
    You can define custom functions that are run before a file is processed by
    Aurora. You can use this feature to save metadata about a page that can then
    be consumed by a template.
</p>
<p>These functions are called &quot;hooks&quot;.</p>
<p>There are three types of hooks, which run:</p>
<ol>
    <li>As a jinja2 filter you can access on all pages (<code>template_filters</code> hook)</li>
    <li>Immediately before a page is generated (<code>pre_generation</code> hook)</li>
    <li>After your site has built (<code>post_build</code> hook)
</ol>


<p>To define a hook, you need to:</p>
<ol>
    <li>Write a hook function with the right type signature, and;</li>
    <li>
        Add the hook function to the
        <code>HOOKS</code> dictionary in your <code>config.py</code> file.
    </li>
</ol>

<p>Below are instructions on how to define each type of hook.</p>

<h2>Filter Hooks</h2>

<p>Filter hooks are registered as a jinja2 filter.</p>

<p>These hooks are useful for manipulating specific values in a template (i.e. formatting dates, changing text).</p>

<p>The type signature of this hook is:</p>

<pre><code class="language-python">
def hook_name(text: str) -&gt; str:
    return text.upper()
</code></pre>

<p>You can register this hook in the <code>template_filter</code> hook:</p>

<pre><code class="language-python">
HOOKS = {
    "template_filter": {
        "example": ["hook_name"]
    }
}
</code></pre>

<p>This hook can then be used in any template on your website:</p>

<pre><code class="language-html">
&lt;h1&gt; "hello world" | hook_name &lt;/h1&gt;
</code></pre>

<h2>Pre-Generation Hooks</h2>

<p>Pre-generation hooks run immediately before a page is generated.</p>

<p>These hooks are useful for adding state to a page for use in rendering (i.e. loading link prveiews from a cache, calculating reading times.)</p>

<p>The type signature of this hook is:</p>

<pre><code class="language-python">
def hook_name(file_name: str, page_state: dict, site_state: dict) -&gt; dict:
    return page_state
</code></pre>

<p>You can register this hook in the <code>template_filter</code> hook:</p>

<pre><code class="language-python">
HOOKS = {
    "pre_generation": {
        "example": ["hook_name"]
    }
}
</code></pre>

<h2>Post-Build Hooks</h2>

<p>Post-build hooks run after your site has been built.</p>

<p>These hooks are useful for performing actions after your site has been built (i.e. saving a log of last generation time, invoking CSS/JS minification).</p>

<p>The type signature of this hook is:</p>

<pre><code class="language-python">
def hook_name(site_state: str) -&gt; None:
    pass
</code></pre>

<p>You can register this hook in the <code>template_filter</code> hook:</p>

<pre><code class="language-python">
HOOKS = {
    "post_build": {
        "example": ["hook_name"]
    }
}
</code></pre>


================================================
FILE: docs/pages/templates/index.html
================================================
---
title: Aurora
layout: default
---

<p>Aurora is a static site generator implemented in Python.</p>
<p>With Aurora, you can generate thousands of static web pages in seconds.</p>
<p>Aurora supports:</p>
<ul>
    <li>Static generation, with support for jinja2 logic</li>
    <li>Incremental Static Regeneration (ISR) with hot reloading</li>
    <li>Markdown and HTML content</li>
    <li>Generating pages from CSV and JSON files</li>
</ul>
<p>Aurora is open source, and licensed under an MIT license.</p>
<p><a href="/aurora/start/">Build your first website with Aurora</a>.</p>
<p><a href="https://github.com/capjamesg/aurora">View source code</a>.</p>
<h2 id="demos">Demo</h2>
<video controls="" autoplay="" loop="" muted="">
    <source
        src="https://github.com/capjamesg/aurora/assets/37276661/39f62bd8-cf5f-4d15-a325-7d433b7ceeb0"
        type="video/mp4"
    />
    Your browser does not support the video tag.
</video>
<p>
    <a href="/aurora/start/"><button>Get Started</button></a>
</p>


================================================
FILE: docs/pages/templates/pagination.md
================================================
---
title: Pagination
layout: default
permalink: /pagination/
---

You can generate pagination pages for collections.

This is ideal if you have a collection with many items that you want to split into multiple pages for ease of navigation.

## Usage

To set up pagination, first [create a collection](/collections/).

Then, create a new layout in your `_layouts` directory. This layout will be used to generate the pagination pages.

This page can access the `page.___` variable, where `___` is the name of the collection (i.e. `page.books` would reference the `page.books` collection). This variable is an array of all the items in a given page in the collection.

To see the current page number, reference the `page.page_number` variable.

Finally, add a `paginators` key to the `SITE_STATE` value in your `config.py` file:

<pre><code class="language-python">SITE_STATE = {
    "paginators": {
        "books": {
            "per_page": 10,
            "template": "books"
        }
    }
}</code></pre>

================================================
FILE: docs/pages/templates/performance.html
================================================
---
title: Performance
layout: default
---

<p>
    In a test generating 292,884 files from a CSV file with a single layer of
    inheritance in each template, Aurora built the website in 140.59 seconds
    (2m:20s).
</p>
<p>
    In a test on a website with 1,763 files and multiple layers of inheritance,
    Aurora built the website in 3.149s. The files in this test were a
    combination of blog posts, static pages, and programmatic archives for blog
    posts (date pages, category pages).
</p>
<p>
    In a test rendering 4,000 markdown files with a single layer of inheritance
    in each template, Aurora built the website in between 0.9 and 1.2 seconds.
</p>
<p>
    In a test comparing 11ty to Aurora in generating the
    <a href="https://github.com/capjamesg/airport-pianos">Airport Pianos</a>
    website (~45 pages), 11ty took 1.36 seconds to start and generate the site,
    whereas Aurora took 0.034 seconds.
</p>


================================================
FILE: docs/pages/templates/permalinks.html
================================================
---
title: Set a Permalink
layout: default
permalink: /permalinks/
---

<p>You can define custom permalinks for a page in its front matter.</p>

<p>To do so, specify the permalink key:</p>

<pre><code class="language-yaml">---
title: Book List
permalink: /books/
layout: default
---</code></pre>

<p>The page above will be generated with the path <code>/books/</code>.</p>


================================================
FILE: docs/pages/templates/robots.html
================================================
---
title: Set a robots.txt File
layout: default
---

<p>robots.txt files let you tell search engines which pages they can and can't index.</p>

<p>To define a robots.txt file, create a file called robots.txt in your <code>pages/templates/</code> directory.</p>

<p>Here's an example of a robots.txt file that lets all bots crawl all pages, and points to your sitemap for reference:</p>

<pre><code class="language-text">User-agent: *
Allow: /

Sitemap: /sitemap.xml
</code></pre>

<h2>See Also</h2>

<ul>
    <li><a href="https://developers.google.com/search/docs/crawling-indexing/robots/create-robots-txt">Google's robots.txt guide</a></li>
</ul>


================================================
FILE: docs/pages/templates/sitemap.html
================================================
---
title: Set a Sitemap
layout: default
---

<p>To define a sitemap, create a file called sitemap.xml in your <code>pages/templates/</code> directory and add the following code:</p>

<pre><code class="language-xml">{% raw %}&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
&lt;urlset xmlns=&quot;http://www.sitemaps.org/schemas/sitemap/0.9&quot;&gt;
    {% for page in site.pages %}
        {% if not page.noindex %}
            &lt;url&gt;
                &lt;loc&gt;
                    {{ page.url }}
                &lt;/loc&gt;
                &lt;lastmod&gt;{{ site.build_time }}&lt;/lastmod&gt;
            &lt;/url&gt;
        {% endif %}
    {% endfor %}
&lt;/urlset&gt;{% endraw %}</code></pre>


================================================
FILE: docs/pages/templates/start.html
================================================
---
title: Start a Website
permalink: /start/
layout: default
---

<p>Learn how to create a website with Aurora.</p>

<h3 id="install-aurora">Install Aurora</h3>
<p>First, install Aurora:</p>
<pre><code class="language-bash">pip3 <span class="hljs-keyword">install</span> aurora-ssg
</code></pre>
<h3 id="create-a-site">Create a Site</h3>
<p>To create a new site, run the following command:</p>
<pre><code class="language-bash">aurora new my-site
cd my-site
</code></pre>
<p>
    This will create a folder called
    <code>my-site</code> with everything you need to start your Aurora site.
</p>
<p>There are two ways to run Aurora:</p>
<ul>
    <li><strong>aurora build</strong>: This command builds your site into a folder called <code>_site</code>, which you can view on your file system.</li>
    <li><strong>aurora serve</strong>: This command builds your site and starts a local server at <code>http://localhost:8000</code> on which your site will run. This is ideal for development.</li>
</ul>

<p>To see your site locally, run:</p>
<pre><code class="language-bash">aurora serve</code></pre>
<p>
    This will start a local server at
    <code>http://localhost:8000</code> on which your site will run.
</p>
<img src="/assets/newsite.png" alt="A web page" />

<p>When you are ready to publish your site, you can run:</p>
<pre><code class="language-bash">aurora build</code></pre>

<p>This will build your site into a folder called <code>_site</code>, which you can then upload to a web server.</p>

<p>You have started an Aurora website 🎉.</p>
<p>
    Next up:
    <a href="/structure/" rel="next">Add a page to your website</a>.
</p>


================================================
FILE: docs/pages/templates/state.html
================================================
---
title: State
permalink: /state/
layout: default
---

<p>There are three types of state in Aurora: page, post and site.</p>

<h2>Page State</h2>

<p>Page state stores values that are only available on that page.</p>

<p>For example, consider the following template:</p>

<pre><code class="language-html">---
title: Hello, World!
layout: default
---

Welcome to the website!
</code></pre>

<p>Any value in the front matter is stored in the page state. This state can be accessed using:</p>

<pre><code class="language-html">{% raw %}{{ page.title }}{% endraw %}</code></pre>

<div class="callout-tip">
    <p><b>Tip</b></p>
    <p>You can access the name of the template from which a page was generated with:</p>

    <pre><code class="language-html">{% raw %}{{ page.generated_from }}{% endraw %}</code></pre>

    <p>This is useful if you want to make a public edit page to a GitHub repository, like the one in the footer of this documentation.</p>
</div>

<h2>Post State</h2>

<p>Post state stores information about a blog post.</p>

<p>You can access post state on any template that is used by a post.</p>

<p>For example, consider the following template called pages/_layouts/post.html for rendering a blog post:</p>

<pre><code class="language-html">---
layout: default
---

&lt;h1&gt;{% raw %}{{ post.title }}{% endraw %}&lt;/h1&gt;

&lt;p&gt;{% raw %}{{ post.content }}{% endraw %}&lt;/p&gt;
</code></pre>

<p>Here, we access the title and content of the post using the post state.</p>

<p>This template (pages/_layouts/post.html) inherits from the default layout, and could be used on any blog post with:</p>

<pre><code class="language-markup">---
layout: post
title: Hello, World!
---

...
</code></pre>

<h2>Site State</h2>

<p>Page state stores values that are global to the website.</p>

<p>You can access site state on any page.</p>

<p>By default, site state contains:</p>

<ul>
    <li>A list of your posts (<code>site.posts</code>)</li>
    <li>The root URL of your site (<code>site.root_url</code>)</li>
    <li>The build date of your site (<code>site.build_date</code>)</li>
    <li>A list of all pages in your site (<code>site.pages</code>)</li>
</ul>

<p>For example, consider the following template:</p>

<pre><code class="language-html">---
title: Blog Home
layout: default
---

{% raw %}
&lt;ul&gt;
    {% for post in site.posts[:5] %}
        &lt;li&gt;
            &lt;a href=&quot;{{ post.url }}&quot;&gt;{{ post.title }}&lt;/a&gt;
        &lt;/li&gt;
    {% endfor %}
&lt;/ul&gt;
{% endraw %}
</code></pre>

<p>Here, we iterate over the first five posts in the site state and display them on the page.</p>

<p>The above code could be used on a home page to display the most recent posts.</p>

<p>You can add custom values to your site state by adding to the SITE_STATE dictionary in your config.py file:</p>

<pre><code class="language-python">SITE_STATE = {
    'site_version': os.getenv('SITE_VERSION', '1.0.0')
}
</code></pre>


================================================
FILE: docs/pages/templates/structure.html
================================================
---
title: Website Structure
permalink: /structure/
layout: default
---

<p>When you create a new Aurora site, some folders and files are created by default.</p>

<p>Below is a list of those files.</p>

<pre><code class="language-text">
├── _site # where your generated site is stored
│   └── index.html
├── assets # where to store your CSS, JS, and images
├── config.py # your Aurora configuration
└── pages # any page in this directory is generated
    ├── _data # store JSON / CSV files to be used in generating pages
    ├── _layouts # page layouts
    ├── posts # blog posts
    └── templates # store single pages to generate
        └── index.html
</code></pre>

<h2>See Also</h2>

<ul>
    <li><a href="/configuration/">Site configuration</a></li>
</ul>


================================================
FILE: docs/pages/templates/templates.md
================================================
---
title: Templates
permalink: /templates/
layout: default
---

Below are templates you can use to get started with Aurora.

<ul id="template-grid">
    <li>
        <img src="https://github.com/capjamesg/aurora-blog-template/raw/main/blog.png" />
        <a href="https://github.com/capjamesg/aurora-blog-template">Blog</a>
    </li>
    <li>
        <img src="https://github.com/capjamesg/aurora-docs-template/raw/main/screenshot.png" />
        <a href="https://github.com/capjamesg/aurora-docs-template">Documentation</a>
    </li>
</li>


================================================
FILE: docs/pages/templates/templating.md
================================================
---
title: Templating with Jinja2
layout: default
permalink: /templating/
---

Aurora supports using [jinja2](https://jinja.palletsprojects.com/en/3.1.x/) to create template logic.

jinja2 is a popular Python templating engine with support for variable interpolation, conditionals, loops, and more.

You can use jinja2 in any HTML or markdown document in your Aurora project.

Here is an example of a Jinja2 template that defines a blog home page:

```html
---
title: Blog
layout: default
permalink: /blog/
---

<h1>Blog</h1>

{% for post in site.posts %}
    <h2>{{ post.title }}</h2>
    <p>{{ post.content }}</p>
{% endfor %}
```


================================================
FILE: docs/pages/templates/users.html
================================================
---
title: Users
layout: default
---

<p>The following sites are built with Aurora:</p>

<ul id="user-grid">
    <li>
        <img src="https://screenshots.jamesg.blog/?url=https://jamesg.blog" />
        <a href="https://jamesg.blog">James&#39; Coffee Blog</a>
        (1,500+ pages)
    </li>
    <li>
        <img
            src="https://screenshots.jamesg.blog/?url=https://airportpianos.org"
        />
        <a href="https://airportpianos.org">Airport Pianos</a>
        (~45 pages)
    </li>
    <li>
        <img
            src="https://screenshots.jamesg.blog/?url=https://trainstationpianos.org"
        />
        <a href="https://trainstationpianos.org">Train Station Pianos</a>
        (~20 pages)
    </li>
    <li>
        <img
            src="https://screenshots.jamesg.blog/?url=https://aurora.jamesg.blog"
        />
        <a href="https://aurora.jamesg.blog">Aurora Documentation</a>
        (~10 pages)
    </li>
</ul>

<p>
    If you would like your site to be featured here, please submit a pull request to the
    <a href="https://github.com/capjamesg/aurora">Aurora GitHub repository</a>.
</p>


================================================
FILE: docs/state.json
================================================
{"last_build": "2024-06-22T19:51:46.751675", "data_file_integrity": {"pages/test/apple/index.html": "542b47f9fbb9dca05694183f48c8fe925e193b45", "pages/test/banana/index.html": "f49e2e069c37a6fc12809ba2db409db23cccfba9", "apple": "542b47f9fbb9dca05694183f48c8fe925e193b45", "banana": "f49e2e069c37a6fc12809ba2db409db23cccfba9"}}

================================================
FILE: requirements.txt
================================================
jinja2
livereload
toposort
pyromark~=0.9.3
python-frontmatter
requests
progress
click
orjson
tqdm
chardet
bs4


================================================
FILE: setup.py
================================================
import re

import setuptools
from setuptools import find_packages

with open("./aurora/__init__.py", "r") as f:
    content = f.read()
    # from https://www.py4u.net/discuss/139845
    version = re.search(r'__version__\s*=\s*[\'"]([^\'"]*)[\'"]', content).group(1)

with open("README.md", "r", encoding="UTF-8") as fh:
    long_description = fh.read()

setuptools.setup(
    name="aurora-ssg",
    version=version,
    author="capjamesg",
    author_email="readers@jamesg.blog",
    description="A fast static site generator implemented in Python.",
    long_description=long_description,
    long_description_content_type="text/markdown",
    url="https://github.com/capjamesg/aurora",
    install_requires=[
        "jinja2",
        "livereload",
        "toposort",
        "pyromark>=0.6,<0.10",
        "python-frontmatter",
        "requests",
        "progress",
        "click",
        "orjson",
        "tqdm",
        "python-dateutil",
        "chardet",
        "bs4"
    ],
    include_package_data=True,
    package_data={"": ["templates/index.html"]},
    packages=find_packages(exclude=("tests",)),
    entry_points={
        "console_scripts": [
            "aurora = aurora.cli:main",
        ],
    },
    extras_require={
        "dev": [
            "flake8",
            "black==25.11.0",
            "isort",
            "twine",
            "pytest",
            "wheel",
            "mkdocs-material",
            "mkdocs",
        ],
    },
    classifiers=[
        "Programming Language :: Python :: 3",
        "License :: OSI Approved :: MIT License",
        "Operating System :: OS Independent",
    ],
    python_requires=">=3.7",
)


================================================
FILE: tests/fixtures/about.html
================================================
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>About - Library</title>
    </head>
    <body>
        <p>We serve 100 readers every month.</p>
    </body>
</html>


================================================
FILE: tests/fixtures/about_ISO-8859-1.html
================================================
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>About ISO Test - Library</title>
    </head>
    <body>
        <p>A test of a file encoded with ISO-8859-1.</p>

<p>We serve 100 readers every month.</p>
    </body>
</html>

================================================
FILE: tests/fixtures/about_UTF-16-BE.html
================================================
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>About UTF-16 BE Test - Library</title>
    </head>
    <body>
        <p>A test of a file encoded with UTF-16 BE.</p>

<p>We serve 100 readers every month.</p>
    </body>
</html>

================================================
FILE: tests/fixtures/about_Windows-1252.html
================================================
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>About Windows-1252 Test - Library</title>
    </head>
    <body>
        <p>A test of a file encoded with Windows-1252	.</p>

<p>We serve 100 readers every month.</p>
    </body>
</html>

================================================
FILE: tests/fixtures/book.html
================================================
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>The Great Gatsby - Library</title>
    </head>
    <body>
        <h1>The Great Gatsby</h1>
<p>F. Scott Fitzgerald</p>
    </body>
</html>


================================================
FILE: tests/fixtures/book_list.html
================================================
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Book List - Library</title>
    </head>
    <body>
        <h2>Books</h2>

<ul>

        <li>
            <a href="/books/the-great-gatsby">The Great Gatsby - F. SCOTT FITZGERALD </a>
        </li>

</ul>
    </body>
</html>


================================================
FILE: tests/fixtures/category_archive.html
================================================
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Category Archive - Library</title>
    </head>
    <body>
        <ul>

        <li>
            <a href="https://example.com/2024/01/01/first-post/">Hello, World!</a>
        </li>

</ul>
    </body>
</html>


================================================
FILE: tests/fixtures/collection_pagination.html
================================================
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Rooms - Library</title>
    </head>
    <body>
        <ul>
    
        <li>
            Study Hall
        </li>
    
        <li>
            Quiet Corner
        </li>
    
</ul>
    </body>
</html>

================================================
FILE: tests/fixtures/date_year.html
================================================
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Date Archive - Library</title>
    </head>
    <body>
        <h1>Posts from 2024</h1>

  <p>Below are the posts we wrote in 2024.</p>






<ul>
    
        <li>
            <a href="https://example.com/2024/01/01/first-post/">Hello, World!</a>
        </li>
    
</ul>
    </body>
</html>

================================================
FILE: tests/fixtures/date_year_month.html
================================================
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Date Archive - Library</title>
    </head>
    <body>
        <h1>Posts from 2024/01</h1>

  <p>Below are the posts we wrote in 2024/01.</p>




<ul>
    
        <li>
            <a href="https://example.com/2024/01/01/first-post/">Hello, World!</a>
        </li>
    
</ul>
    </body>
</html>

================================================
FILE: tests/fixtures/date_year_month_day.html
================================================
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Date Archive - Library</title>
    </head>
    <body>
        <h1>Posts from January 01, 2024</h1>

  <p>Below are the posts we wrote on January 01, 2024.</p>


<ul>
    
        <li>
            <a href="https://example.com/2024/01/01/first-post/">Hello, World!</a>
        </li>
    
</ul>
    </body>
</html>

================================================
FILE: tests/fixtures/index.html
================================================
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Library</title>
    </head>
    <body>
        <h1>Welcome to the library!</h1>
        <p><a href="/books/">Browse the books.</a></p>
    </body>
</html>


================================================
FILE: tests/fixtures/new_site_config.py
================================================
import os

BASE_URLS = {
    "local": os.getcwd(),
    "production": "https://example.com",
}

SITE_ENV = os.environ.get("SITE_ENV", "local")
BASE_URL = BASE_URLS[SITE_ENV]
ROOT_DIR = "pages"
LAYOUTS_BASE_DIR = "_layouts"
SITE_DIR = "_site"
HOOKS = {}
SITE_STATE = {}


================================================
FILE: tests/fixtures/post.html
================================================
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Hello, World! - Library Blog</title>
    </head>
    <body>
        <p>This is our first blog post.</p>

    </body>
</html>


================================================
FILE: tests/fixtures/review.html
================================================
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Review by James - Library</title>
    </head>
    <body>
        <h1>James</h1>

<p>An excellent book.</p>

<p> 5/5 stars</p>
    </body>
</html>


================================================
FILE: tests/fixtures/robots.txt
================================================
User-Agent: *
Allow: /


================================================
FILE: tests/fixtures/styles.css
================================================
* {
    font-family: San system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}


================================================
FILE: tests/fixtures/tag_archive.html
================================================
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Tag Archive - Library</title>
    </head>
    <body>
        <ul>
    
        <li>
            <a href="https://example.com/2024/01/01/first-post/">Hello, World!</a>
        </li>
    
</ul>
    </body>
</html>

================================================
FILE: tests/library/assets/meta/robots.txt
================================================
User-Agent: *
Allow: /


================================================
FILE: tests/library/assets/styles.css
================================================
* {
    font-family: San system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}


================================================
FILE: tests/library/config.py
================================================
import os

BASE_URLS = {
    "local": os.getcwd(),
    "production": "https://example.com",
}

SITE_ENV = os.environ.get("SITE_ENV", "local")
BASE_URL = BASE_URLS[SITE_ENV]
ROOT_DIR = "pages"
LAYOUTS_BASE_DIR = "_layouts"
SITE_DIR = "_site"
HOOKS = {
    "pre_template_generation": {"hooks": ["retrieve_visitor_count"]},
    "post_build": {"hooks": ["add_made_by_file"]},
    "template_filters": {"hooks": ["capitalize"]},
}
SITE_STATE = {
    "category_slug_root": "category",
    "tag_slug_root": "tag",
    "paginators": {"rooms": {"per_page": 50, "template": "rooms"}},
}


================================================
FILE: tests/library/hooks.py
================================================
def retrieve_visitor_count(file_name, page_state, _):
    page_state["visitors"] = 100

    return page_state


def add_made_by_file(state):
    with open("_site/made-by.txt", "w") as f:
        f.write("Made by the library team.")

    return state


def capitalize(text):
    return text.upper()


================================================
FILE: tests/library/pages/_data/books.json
================================================
[
  {"title": "The Great Gatsby", "author": "F. Scott Fitzgerald", "layout": "book-template", "slug": "the-great-gatsby"}
]


================================================
FILE: tests/library/pages/_data/reviews.csv
================================================
name,review,star,layout
James,An excellent book., 5,reader-review

================================================
FILE: tests/library/pages/_layouts/book-template.html
================================================
---
layout: default
title: "{{ page.title }}"
---

<h1>{{ page.title }}</h1>
<p>{{ page.author }}</p>


================================================
FILE: tests/library/pages/_layouts/category.html
================================================
---
layout: default
title: "Category Archive"
---

<ul>
    {% for post in page.posts %}
        <li>
            <a href="{{ post.url }}">{{ post.title }}</a>
        </li>
    {% endfor %}
</ul>


================================================
FILE: tests/library/pages/_layouts/date.html
================================================
---
layout: default
title: "Date Archive"
---
{% if page.date_type == "year" %}
  <h1>Posts from {{ page.date | year }}</h1>

  <p>Below are the posts we wrote in {{ page.date | year }}.</p>
{% endif %}

{% if page.date_type == "month" %}
  <h1>Posts from {{ page.date | archive_date }}</h1>

  <p>Below are the posts we wrote in {{ page.date | archive_date }}.</p>
{% endif %}

{% if page.date_type == "day" %}
  <h1>Posts from {{ page.date | long_date }}</h1>

  <p>Below are the posts we wrote on {{ page.date | long_date }}.</p>
{% endif %}

<ul>
    {% for post in page.posts %}
        <li>
            <a href="{{ post.url }}">{{ post.title }}</a>
        </li>
    {% endfor %}
</ul>


================================================
FILE: tests/library/pages/_layouts/default.html
================================================
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>{{ page.title }} - Library</title>
    </head>
    <body>
        {{ content }}
    </body>
</html>


================================================
FILE: tests/library/pages/_layouts/post.html
================================================
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>{{ post.title }} - Library Blog</title>
    </head>
    <body>
        {{ content }}
    </body>
</html>


================================================
FILE: tests/library/pages/_layouts/reader-review.html
================================================
---
layout: default
title: "Review by {{ page.name }}"
---

<h1>{{ page.name }}</h1>

<p>{{ page.review }}</p>

<p>{{ page.star }}/5 stars</p>


================================================
FILE: tests/library/pages/_layouts/rooms.html
================================================
---
layout: default
title: Rooms
---

<ul>
    {% for room in page.current_page %}
        <li>
            {{ room.title }}
        </li>
    {% endfor %}
</ul>


================================================
FILE: tests/library/pages/_layouts/tag.html
================================================
---
layout: default
title: "Tag Archive"
---

<ul>
    {% for post in page.posts %}
        <li>
            <a href="{{ post.url }}">{{ post.title }}</a>
        </li>
    {% endfor %}
</ul>


================================================
FILE: tests/library/pages/posts/2024-01-01-first-post.md
================================================
---
title: "Hello, World!"
layout: post
categories:
- Featured
tags:
- Announcements
---

This is our first blog post.


================================================
FILE: tests/library/pages/rooms/quiet-corner.html
================================================
---
title: Quiet Corner
layout: default
collection: rooms
---

<h1>Quiet Corner</h1>

================================================
FILE: tests/library/pages/rooms/study-hall.html
================================================
---
title: Study Hall
layout: default
collection: rooms
---

<h1>Study Hall</h1>

================================================
FILE: tests/library/pages/templates/about.html
================================================
---
title: About
layout: default
---

<p>We serve {{ page.visitors }} readers every month.</p>


================================================
FILE: tests/library/pages/templates/about_ISO-8859-1.html
================================================
---
title: About ISO Test
layout: default
---

<p>A test of a file encoded with ISO-8859-1.</p>

<p>We serve {{ page.visitors }} readers every month.</p>


================================================
FILE: tests/library/pages/templates/about_Windows-1252.html
================================================
---
title: About Windows-1252 Test
layout: default
---

<p>A test of a file encoded with Windows-1252	.</p>

<p>We serve {{ page.visitors }} readers every month.</p>


================================================
FILE: tests/library/pages/templates/book_list.html
================================================
---
title: Book List
permalink: /book-list/
layout: default
---

<h2>Books</h2>

<ul>
    {% for book in site.books %}
        <li>
            <a href="/books/{{ book.slug }}">{{ book.title }} - {{ book.author | capitalize }} </a>
        </li>
    {% endfor %}
</ul>


================================================
FILE: tests/library/pages/templates/index.html
================================================
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Library</title>
    </head>
    <body>
        <h1>Welcome to the library!</h1>
        <p><a href="/books/">Browse the books.</a></p>
    </body>
</html>


================================================
FILE: tests/library/state.json
================================================
{"last_build": "2024-08-08T10:55:05.167577", "data_file_integrity": {"0": "83cab347b81062bf833f9de2a5f117ee63b02f99", "the-great-gatsby": "99a8216ddb24aee98b88bf4305c53882aee58d4c"}}

================================================
FILE: tests/state.py
================================================
import os
import shutil

TEST_FOLDER = os.path.join(os.getcwd(), "tests/library")
BASE_SITE_DIRECTORY = os.path.join(TEST_FOLDER, "_site")
FIXTURES_DIRECTORY = os.path.join(os.getcwd(), "tests/fixtures")

os.chdir(TEST_FOLDER)

fixtures = {}

for file in os.listdir(FIXTURES_DIRECTORY):
    with open(os.path.join(FIXTURES_DIRECTORY, file)) as f:
        fixtures[file] = f.read()

from aurora.graph import main as build_site


def test_build_site():
    build_site()
    assert os.path.exists("_site")


def test_config_file_presence():
    assert os.path.exists("config.py")


def test_rendered_page_from_data_file():
    with open(
        os.path.join(BASE_SITE_DIRECTORY, "books/the-great-gatsby/index.html")
    ) as f:
        data = f.read()

    assert data.strip() == fixtures["book.html"].strip()


def test_rendered_page_from_data_file_without_slug():
    with open(os.path.join(BASE_SITE_DIRECTORY, "reviews/0/index.html")) as f:
        data = f.read()

    assert data.strip() == fixtures["review.html"].strip()


def test_rendered_page_from_template():
    # also tests title interpolation
    with open(os.path.join(BASE_SITE_DIRECTORY, "index.html")) as f:
        data = f.read()

    assert data.strip() == fixtures["index.html"].strip()


def test_permalink_front_matter():
    assert os.path.exists(os.path.join(BASE_SITE_DIRECTORY, "book-list/index.html"))


def test_rendered_page_with_logic():
    # this also tests:
    # - inheritance working (inheriting from `default`)
    # - title interpolation working
    with open(os.path.join(BASE_SITE_DIRECTORY, "book-list/index.html")) as f:
        data = f.read()

    assert data.replace(" ", "").replace("\n", "") == fixtures[
        "book_list.html"
    ].replace(" ", "").replace("\n", "")


def test_asset_copying():
    with open(os.path.join(BASE_SITE_DIRECTORY, "assets/styles.css")) as f:
        data = f.read()

    assert data.strip() == fixtures["styles.css"].strip()


def test_asset_copying_in_folders():
    with open(os.path.join(BASE_SITE_DIRECTORY, "assets/meta/robots.txt")) as f:
        data = f.read()

    assert data.strip() == fixtures["robots.txt"].strip()


def test_generate_blog_post():
    with open(
        os.path.join(BASE_SITE_DIRECTORY, "2024/01/01/first-post/index.html")
    ) as f:
        data = f.read()

    assert data.strip() == fixtures["post.html"].strip()


def test_new_site_generation():
    os.system("aurora new test-site")
    assert os.path.exists("test-site")
    assert os.path.exists("test-site/assets")
    assert os.path.exists("test-site/pages")
    assert os.path.exists("test-site/pages/_layouts")
    assert os.path.exists("test-site/pages/_data")
    assert os.path.exists("test-site/pages/posts")
    assert os.path.exists("test-site/pages/templates/index.html")

    with open("test-site/config.py") as f:
        data = f.read()

    assert data.strip() == fixtures["new_site_config.py"].strip()

    shutil.rmtree("test-site")


def test_pre_generation_hook():
    # this page uses {{ page.visitors }}, which is computed in a pre-generation hook
    with open(os.path.join(BASE_SITE_DIRECTORY, "about/index.html")) as f:
        data = f.read()

    assert data.strip() == fixtures["about.html"].strip()


def test_post_build_hook():
    # check for presence of site/made-by.txt
    assert os.path.exists(os.path.join(BASE_SITE_DIRECTORY, "made-by.txt"))


def test_year_date_archive_generation():
    with open(os.path.join(BASE_SITE_DIRECTORY, "2024/index.html")) as f:
        data = f.read()

    assert data.strip().replace(" ", "").replace("\n", "") == fixtures[
        "date_year.html"
    ].strip().replace(" ", "").replace("\n", "")


def test_year_month_date_archive_generation():
    with open(os.path.join(BASE_SITE_DIRECTORY, "2024/01/index.html")) as f:
        data = f.read()

    assert data.strip().replace(" ", "").replace("\n", "") == fixtures[
        "date_year_month.html"
    ].strip().replace(" ", "").replace("\n", "")


def test_year_month_day_date_archive_generation():
    with open(os.path.join(BASE_SITE_DIRECTORY, "2024/01/01/index.html")) as f:
        data = f.read()

    assert data.strip().replace(" ", "").replace("\n", "") == fixtures[
        "date_year_month_day.html"
    ].strip().replace(" ", "").replace("\n", "")


def test_tag_archive_generation():
    with open(os.path.join(BASE_SITE_DIRECTORY, "tag/announcements/index.html")) as f:
        data = f.read()

    assert data.strip().replace(" ", "").replace("\n", "") == fixtures[
        "tag_archive.html"
    ].strip().replace(" ", "").replace("\n", "")


def test_collection_pagination():
    with open(os.path.join(BASE_SITE_DIRECTORY, "rooms/index.html")) as f:
        data = f.read()

    assert data.strip().replace(" ", "").replace("\n", "") == fixtures[
        "collection_pagination.html"
    ].strip().replace(" ", "").replace("\n", "")


def check_for_presence_of_state_file_after_build():
    assert os.path.exists("state.json")


def test_incremental_regeneration():
    generated_files = []

    for root, _, files in os.walk("_site"):
        for file in files:
            generated_files.append(os.path.relpath(os.path.join(root, file), "_site"))

    os.system("aurora build --incremental")

    new_generated_files = []

    for root, _, files in os.walk("_site"):
        for file in files:
            new_generated_files.append(
                os.path.relpath(os.path.join(root, file), "_site")
            )

    assert set(generated_files) == set(new_generated_files)
Download .txt
gitextract_c93td2o_/

├── .github/
│   ├── ISSUE_TEMPLATE/
│   │   └── bug-report.yml
│   ├── dependabot.yml
│   └── workflows/
│       ├── benchmark.yml
│       ├── docs.yml
│       ├── full-site-tests.yml
│       ├── release.yml
│       ├── test.yml
│       └── welcome.yml
├── .gitignore
├── .pre-commit-config.yaml
├── CHANGELOG.md
├── CITATION.cff
├── LICENSE
├── Makefile
├── README.md
├── aurora/
│   ├── __init__.py
│   ├── cli.py
│   ├── date_helpers.py
│   ├── graph.py
│   └── templates/
│       └── index.html
├── docs/
│   ├── assets/
│   │   ├── prism.css
│   │   └── prism.js
│   ├── config.py
│   ├── highlighting.py
│   ├── pages/
│   │   ├── _layouts/
│   │   │   └── default.html
│   │   └── templates/
│   │       ├── 404.html
│   │       ├── archives.md
│   │       ├── blog.html
│   │       ├── build-methods.md
│   │       ├── collections-from-data.html
│   │       ├── collections.md
│   │       ├── configuration.html
│   │       ├── dates.html
│   │       ├── design.html
│   │       ├── hooks.html
│   │       ├── index.html
│   │       ├── pagination.md
│   │       ├── performance.html
│   │       ├── permalinks.html
│   │       ├── robots.html
│   │       ├── sitemap.html
│   │       ├── start.html
│   │       ├── state.html
│   │       ├── structure.html
│   │       ├── templates.md
│   │       ├── templating.md
│   │       └── users.html
│   └── state.json
├── requirements.txt
├── setup.py
└── tests/
    ├── fixtures/
    │   ├── about.html
    │   ├── about_ISO-8859-1.html
    │   ├── about_UTF-16-BE.html
    │   ├── about_Windows-1252.html
    │   ├── book.html
    │   ├── book_list.html
    │   ├── category_archive.html
    │   ├── collection_pagination.html
    │   ├── date_year.html
    │   ├── date_year_month.html
    │   ├── date_year_month_day.html
    │   ├── index.html
    │   ├── new_site_config.py
    │   ├── post.html
    │   ├── review.html
    │   ├── robots.txt
    │   ├── styles.css
    │   └── tag_archive.html
    ├── library/
    │   ├── assets/
    │   │   ├── meta/
    │   │   │   └── robots.txt
    │   │   └── styles.css
    │   ├── config.py
    │   ├── hooks.py
    │   ├── pages/
    │   │   ├── _data/
    │   │   │   ├── books.json
    │   │   │   └── reviews.csv
    │   │   ├── _layouts/
    │   │   │   ├── book-template.html
    │   │   │   ├── category.html
    │   │   │   ├── date.html
    │   │   │   ├── default.html
    │   │   │   ├── post.html
    │   │   │   ├── reader-review.html
    │   │   │   ├── rooms.html
    │   │   │   └── tag.html
    │   │   ├── posts/
    │   │   │   └── 2024-01-01-first-post.md
    │   │   ├── rooms/
    │   │   │   ├── quiet-corner.html
    │   │   │   └── study-hall.html
    │   │   └── templates/
    │   │       ├── about.html
    │   │       ├── about_ISO-8859-1.html
    │   │       ├── about_UTF-16-BE.html
    │   │       ├── about_Windows-1252.html
    │   │       ├── book_list.html
    │   │       └── index.html
    │   └── state.json
    └── state.py
Download .txt
SYMBOL INDEX (68 symbols across 7 files)

FILE: aurora/cli.py
  function main (line 10) | def main():
  function new (line 16) | def new(name):
  function build (line 66) | def build(incremental):
  function serve (line 77) | def serve():

FILE: aurora/date_helpers.py
  function month_number_to_written_month (line 6) | def month_number_to_written_month(month):
  function list_archive_date (line 10) | def list_archive_date(date):
  function long_date (line 17) | def long_date(date):
  function date_to_xml_string (line 21) | def date_to_xml_string(date):
  function archive_date (line 25) | def archive_date(date):
  function year (line 29) | def year(date):

FILE: aurora/graph.py
  class Post (line 90) | class Post:
    method __init__ (line 91) | def __init__(self, front_matter):
    method __getattr__ (line 94) | def __getattr__(self, name):
    method serialize_as_json (line 97) | def serialize_as_json(self):
  function read_file (line 158) | def read_file(file_name, mode="r") -> str:
  function slugify (line 177) | def slugify(value: str) -> str:
  class VariableVisitor (line 184) | class VariableVisitor(NodeVisitor):
    method __init__ (line 189) | def __init__(self):
    method visit_Name (line 192) | def visit_Name(self, node, *args, **kwargs) -> None:
    method visit_Getattr (line 196) | def visit_Getattr(self, node, *args, **kwargs) -> None:
  function get_file_dependencies_and_evaluated_contents (line 209) | def get_file_dependencies_and_evaluated_contents(
  function make_any_nonexistent_directories (line 363) | def make_any_nonexistent_directories(path: str) -> None:
  function interpolate_front_matter (line 368) | def interpolate_front_matter(front_matter: dict, state: dict, runtime = ...
  function recursively_build_page_template_with_front_matter (line 393) | def recursively_build_page_template_with_front_matter(
  function render_page (line 447) | def render_page(file: str, skip_hooks=False) -> None:
  function generate_date_page_given_year_month_date (line 644) | def generate_date_page_given_year_month_date(
  function generate_paginated_page_for_collection (line 700) | def generate_paginated_page_for_collection(
  function process_date_archives (line 785) | def process_date_archives() -> None:
  function process_archives (line 867) | def process_archives(name: str, state_key_associated_with_name: str, pat...
  function copy_asset_to_site (line 937) | def copy_asset_to_site(assets: list) -> None:
  function get_state_from_last_build (line 951) | def get_state_from_last_build() -> dict:
  function calculate_dependencies_from_saved_state (line 964) | def calculate_dependencies_from_saved_state(all_dependencies: dict) -> l...
  function load_data_from_data_files (line 995) | def load_data_from_data_files(deps: list, data_file_integrity: dict) -> ...
  function get_data_files_in_folder (line 1059) | def get_data_files_in_folder(folder: str) -> list:
  function main (line 1071) | def main(deps: list = [], watch: bool = False, incremental: bool = False...

FILE: docs/assets/prism.js
  function u (line 3) | function u(e){s.highlightedCode=e,a.hooks.run("before-insert",s),s.eleme...
  function i (line 3) | function i(e,n,t,r){this.type=e,this.content=n,this.alias=t,this.length=...
  function l (line 3) | function l(e,n,t,r){e.lastIndex=n;var a=e.exec(t);if(a&&r&&a[1]){var i=a...
  function o (line 3) | function o(e,n,t,r,s,g){for(var f in t)if(t.hasOwnProperty(f)&&t[f]){var...
  function s (line 3) | function s(){var e={value:null,prev:null,next:null},n={value:null,prev:e...
  function u (line 3) | function u(e,n,t){var r=n.next,a={value:t,prev:n,next:r};return n.next=a...
  function c (line 3) | function c(e,n,t){for(var r=n.next,a=0;a<t&&r!==e.tail;a++)r=r.next;n.ne...
  function f (line 3) | function f(){a.manual||a.highlightAll()}

FILE: docs/highlighting.py
  function highlight_code (line 14) | def highlight_code(file_name, page_state, _, page_contents):
  function generate_table_of_contents (line 65) | def generate_table_of_contents(file_name, page_state, site_state):

FILE: tests/library/hooks.py
  function retrieve_visitor_count (line 1) | def retrieve_visitor_count(file_name, page_state, _):
  function add_made_by_file (line 7) | def add_made_by_file(state):
  function capitalize (line 14) | def capitalize(text):

FILE: tests/state.py
  function test_build_site (line 19) | def test_build_site():
  function test_config_file_presence (line 24) | def test_config_file_presence():
  function test_rendered_page_from_data_file (line 28) | def test_rendered_page_from_data_file():
  function test_rendered_page_from_data_file_without_slug (line 37) | def test_rendered_page_from_data_file_without_slug():
  function test_rendered_page_from_template (line 44) | def test_rendered_page_from_template():
  function test_permalink_front_matter (line 52) | def test_permalink_front_matter():
  function test_rendered_page_with_logic (line 56) | def test_rendered_page_with_logic():
  function test_asset_copying (line 68) | def test_asset_copying():
  function test_asset_copying_in_folders (line 75) | def test_asset_copying_in_folders():
  function test_generate_blog_post (line 82) | def test_generate_blog_post():
  function test_new_site_generation (line 91) | def test_new_site_generation():
  function test_pre_generation_hook (line 109) | def test_pre_generation_hook():
  function test_post_build_hook (line 117) | def test_post_build_hook():
  function test_year_date_archive_generation (line 122) | def test_year_date_archive_generation():
  function test_year_month_date_archive_generation (line 131) | def test_year_month_date_archive_generation():
  function test_year_month_day_date_archive_generation (line 140) | def test_year_month_day_date_archive_generation():
  function test_tag_archive_generation (line 149) | def test_tag_archive_generation():
  function test_collection_pagination (line 158) | def test_collection_pagination():
  function check_for_presence_of_state_file_after_build (line 167) | def check_for_presence_of_state_file_after_build():
  function test_incremental_regeneration (line 171) | def test_incremental_regeneration():
Condensed preview — 93 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (185K chars).
[
  {
    "path": ".github/ISSUE_TEMPLATE/bug-report.yml",
    "chars": 1075,
    "preview": "name: Bug Report\ndescription: File a bug with Aurora.\nlabels: [bug]\nbody:\n  - type: markdown\n    attributes:\n      value"
  },
  {
    "path": ".github/dependabot.yml",
    "chars": 203,
    "preview": "version: 2\nupdates:\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"daily\"\n  -"
  },
  {
    "path": ".github/workflows/benchmark.yml",
    "chars": 1092,
    "preview": "name: Run benchmark (200k pages+)\n\non: workflow_dispatch\n\njobs:\n  build:\n    runs-on: ${{ matrix.os }}\n    strategy:\n   "
  },
  {
    "path": ".github/workflows/docs.yml",
    "chars": 1077,
    "preview": "name: Publish documentation\n\non:\n  push:\n    branches:\n      - main\n\njobs:\n  build:\n\n    runs-on: ubuntu-latest\n    stra"
  },
  {
    "path": ".github/workflows/full-site-tests.yml",
    "chars": 1566,
    "preview": "name: Test several sites built with Aurora\n\non:\n  push:\n    branches:\n      - main\n\njobs:\n  build:\n    runs-on: ${{ matr"
  },
  {
    "path": ".github/workflows/release.yml",
    "chars": 881,
    "preview": "name: Publish WorkFlow\n\non:\n  release:\n    types: [created]\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    strategy:\n   "
  },
  {
    "path": ".github/workflows/test.yml",
    "chars": 821,
    "preview": "name: Aurora Test Suite\n\non:\n  pull_request:\n    branches: [main]\n  push:\n    branches: [main]\n\njobs:\n  build-dev-test:\n"
  },
  {
    "path": ".github/workflows/welcome.yml",
    "chars": 448,
    "preview": "name: Welcome\n\non: [pull_request, issues]\n\njobs:\n  greeting:\n    name: 👋 Welcome\n    runs-on: ubuntu-latest\n    steps:\n "
  },
  {
    "path": ".gitignore",
    "chars": 2280,
    "preview": "# env specific\nconfig.json\n.env\n# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n.idea\n# C exte"
  },
  {
    "path": ".pre-commit-config.yaml",
    "chars": 1,
    "preview": "\n"
  },
  {
    "path": "CHANGELOG.md",
    "chars": 597,
    "preview": "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Change"
  },
  {
    "path": "CITATION.cff",
    "chars": 535,
    "preview": "# This CITATION.cff file was generated with cffinit.\n# Visit https://bit.ly/cffinit to generate yours today!\n\ncff-versio"
  },
  {
    "path": "LICENSE",
    "chars": 1061,
    "preview": "MIT License\n\nCopyright (c) 2024 James\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof t"
  },
  {
    "path": "Makefile",
    "chars": 762,
    "preview": ".PHONY: style check_code_quality\n\nexport PYTHONPATH = .\ncheck_dirs := aurora\n\nstyle:\n\tblack  $(check_dirs)\n\tisort --prof"
  },
  {
    "path": "README.md",
    "chars": 9812,
    "preview": "![Banner](banner.png)\n\n<div align=\"center\">\n\n[![version](https://badge.fury.io/py/aurora-ssg.svg)](https://badge.fury.io"
  },
  {
    "path": "aurora/__init__.py",
    "chars": 22,
    "preview": "__version__ = \"0.1.7\"\n"
  },
  {
    "path": "aurora/cli.py",
    "chars": 1805,
    "preview": "import os\n\nimport click\n\nfrom . import __version__\n\n\n@click.group()\n@click.version_option(version=__version__)\ndef main("
  },
  {
    "path": "aurora/date_helpers.py",
    "chars": 600,
    "preview": "import datetime\n\nimport dateutil.parser\n\n\ndef month_number_to_written_month(month):\n    return datetime.datetime.strptim"
  },
  {
    "path": "aurora/graph.py",
    "chars": 48143,
    "preview": "import logging\nimport os\nimport sys\n\nif not os.path.exists(\"config.py\"):\n    raise Exception(\"config.py not found\")\n\nimp"
  },
  {
    "path": "aurora/templates/index.html",
    "chars": 1327,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width="
  },
  {
    "path": "docs/assets/prism.css",
    "chars": 1887,
    "preview": "/* PrismJS 1.29.0\nhttps://prismjs.com/download.html#themes=prism&languages=markup+bash+python */\ncode[class*=language-],"
  },
  {
    "path": "docs/assets/prism.js",
    "chars": 18629,
    "preview": "/* PrismJS 1.29.0\nhttps://prismjs.com/download.html#themes=prism&languages=markup+bash+python */\nvar _self=\"undefined\"!="
  },
  {
    "path": "docs/config.py",
    "chars": 441,
    "preview": "import os\n\nBASE_URLS = {\n    \"local\": \"http://localhost:8000/\",\n    \"production\": \"https://jamesg.blog/aurora/\",\n}\n\nSITE"
  },
  {
    "path": "docs/highlighting.py",
    "chars": 2740,
    "preview": "from bs4 import BeautifulSoup\nfrom pygments import highlight\nfrom pygments.formatters import HtmlFormatter\nfrom pygments"
  },
  {
    "path": "docs/pages/_layouts/default.html",
    "chars": 23568,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width"
  },
  {
    "path": "docs/pages/templates/404.html",
    "chars": 131,
    "preview": "---\ntitle: 404\npermalink: /404.html\nlayout: default\n---\n\n<p>This page does not exist. <a href=\"/\">Go back to the homepag"
  },
  {
    "path": "docs/pages/templates/archives.md",
    "chars": 1554,
    "preview": "---\ntitle: Date, Category, and Tag Archives\nlayout: default\npermalink: /archives/\n---\n\nAurora has support built-in for g"
  },
  {
    "path": "docs/pages/templates/blog.html",
    "chars": 3460,
    "preview": "---\ntitle: Use Aurora as a Blog\npermalink: /blog/\nlayout: default\n---\n\n<p>Aurora has out-of-the-box features designed fo"
  },
  {
    "path": "docs/pages/templates/build-methods.md",
    "chars": 2493,
    "preview": "---\ntitle: Build Methods\npermalink: /build-methods/\nlayout: default\n---\n\nThere are three ways you can build your Aurora "
  },
  {
    "path": "docs/pages/templates/collections-from-data.html",
    "chars": 2661,
    "preview": "---\ntitle: Create a Collection from Data\npermalink: /collections-from-data/\nlayout: default\n---\n\n<p>You can turn data fr"
  },
  {
    "path": "docs/pages/templates/collections.md",
    "chars": 3144,
    "preview": "---\ntitle: Data Collections\npermalink: /collections/\nlayout: default\n---\n\nData collections are groups of data on a websi"
  },
  {
    "path": "docs/pages/templates/configuration.html",
    "chars": 1745,
    "preview": "---\ntitle: Configure Your Website\nlayout: default\n---\n\n<p>\n    You need a <code>config.py</code> file in the directory i"
  },
  {
    "path": "docs/pages/templates/dates.html",
    "chars": 772,
    "preview": "---\nlayout: default\ntitle: Date Handling\npermalink: /dates/\n---\n\n<p>Aurora has several default filters that you can use "
  },
  {
    "path": "docs/pages/templates/design.html",
    "chars": 558,
    "preview": "---\ntitle: Aurora Design\npermalink: /design/\nlayout: default\n---\n\n<p>I have written several blog posts that explore the "
  },
  {
    "path": "docs/pages/templates/hooks.html",
    "chars": 2907,
    "preview": "---\ntitle: Hooks\nlayout: default\npermalink: /hooks/\n---\n\n<p>\n    You can define custom functions that are run before a f"
  },
  {
    "path": "docs/pages/templates/index.html",
    "chars": 1006,
    "preview": "---\ntitle: Aurora\nlayout: default\n---\n\n<p>Aurora is a static site generator implemented in Python.</p>\n<p>With Aurora, y"
  },
  {
    "path": "docs/pages/templates/pagination.md",
    "chars": 1007,
    "preview": "---\ntitle: Pagination\nlayout: default\npermalink: /pagination/\n---\n\nYou can generate pagination pages for collections.\n\nT"
  },
  {
    "path": "docs/pages/templates/performance.html",
    "chars": 931,
    "preview": "---\ntitle: Performance\nlayout: default\n---\n\n<p>\n    In a test generating 292,884 files from a CSV file with a single lay"
  },
  {
    "path": "docs/pages/templates/permalinks.html",
    "chars": 373,
    "preview": "---\ntitle: Set a Permalink\nlayout: default\npermalink: /permalinks/\n---\n\n<p>You can define custom permalinks for a page i"
  },
  {
    "path": "docs/pages/templates/robots.html",
    "chars": 650,
    "preview": "---\ntitle: Set a robots.txt File\nlayout: default\n---\n\n<p>robots.txt files let you tell search engines which pages they c"
  },
  {
    "path": "docs/pages/templates/sitemap.html",
    "chars": 722,
    "preview": "---\ntitle: Set a Sitemap\nlayout: default\n---\n\n<p>To define a sitemap, create a file called sitemap.xml in your <code>pag"
  },
  {
    "path": "docs/pages/templates/start.html",
    "chars": 1640,
    "preview": "---\ntitle: Start a Website\npermalink: /start/\nlayout: default\n---\n\n<p>Learn how to create a website with Aurora.</p>\n\n<h"
  },
  {
    "path": "docs/pages/templates/state.html",
    "chars": 2961,
    "preview": "---\ntitle: State\npermalink: /state/\nlayout: default\n---\n\n<p>There are three types of state in Aurora: page, post and sit"
  },
  {
    "path": "docs/pages/templates/structure.html",
    "chars": 761,
    "preview": "---\ntitle: Website Structure\npermalink: /structure/\nlayout: default\n---\n\n<p>When you create a new Aurora site, some fold"
  },
  {
    "path": "docs/pages/templates/templates.md",
    "chars": 543,
    "preview": "---\ntitle: Templates\npermalink: /templates/\nlayout: default\n---\n\nBelow are templates you can use to get started with Aur"
  },
  {
    "path": "docs/pages/templates/templating.md",
    "chars": 633,
    "preview": "---\ntitle: Templating with Jinja2\nlayout: default\npermalink: /templating/\n---\n\nAurora supports using [jinja2](https://ji"
  },
  {
    "path": "docs/pages/templates/users.html",
    "chars": 1125,
    "preview": "---\ntitle: Users\nlayout: default\n---\n\n<p>The following sites are built with Aurora:</p>\n\n<ul id=\"user-grid\">\n    <li>\n  "
  },
  {
    "path": "docs/state.json",
    "chars": 327,
    "preview": "{\"last_build\": \"2024-06-22T19:51:46.751675\", \"data_file_integrity\": {\"pages/test/apple/index.html\": \"542b47f9fbb9dca0569"
  },
  {
    "path": "requirements.txt",
    "chars": 110,
    "preview": "jinja2\nlivereload\ntoposort\npyromark~=0.9.3\npython-frontmatter\nrequests\nprogress\nclick\norjson\ntqdm\nchardet\nbs4\n"
  },
  {
    "path": "setup.py",
    "chars": 1669,
    "preview": "import re\n\nimport setuptools\nfrom setuptools import find_packages\n\nwith open(\"./aurora/__init__.py\", \"r\") as f:\n    cont"
  },
  {
    "path": "tests/fixtures/about.html",
    "chars": 285,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width="
  },
  {
    "path": "tests/fixtures/about_ISO-8859-1.html",
    "chars": 343,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width="
  },
  {
    "path": "tests/fixtures/about_UTF-16-BE.html",
    "chars": 348,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width="
  },
  {
    "path": "tests/fixtures/about_Windows-1252.html",
    "chars": 355,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width="
  },
  {
    "path": "tests/fixtures/book.html",
    "chars": 308,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width="
  },
  {
    "path": "tests/fixtures/book_list.html",
    "chars": 394,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width="
  },
  {
    "path": "tests/fixtures/category_archive.html",
    "chars": 378,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width="
  },
  {
    "path": "tests/fixtures/collection_pagination.html",
    "chars": 371,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width="
  },
  {
    "path": "tests/fixtures/date_year.html",
    "chars": 460,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width="
  },
  {
    "path": "tests/fixtures/date_year_month.html",
    "chars": 464,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width="
  },
  {
    "path": "tests/fixtures/date_year_month_day.html",
    "chars": 480,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width="
  },
  {
    "path": "tests/fixtures/index.html",
    "chars": 324,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width="
  },
  {
    "path": "tests/fixtures/new_site_config.py",
    "chars": 268,
    "preview": "import os\n\nBASE_URLS = {\n    \"local\": os.getcwd(),\n    \"production\": \"https://example.com\",\n}\n\nSITE_ENV = os.environ.get"
  },
  {
    "path": "tests/fixtures/post.html",
    "chars": 294,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width="
  },
  {
    "path": "tests/fixtures/review.html",
    "chars": 315,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width="
  },
  {
    "path": "tests/fixtures/robots.txt",
    "chars": 23,
    "preview": "User-Agent: *\nAllow: /\n"
  },
  {
    "path": "tests/fixtures/styles.css",
    "chars": 163,
    "preview": "* {\n    font-family: San system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'O"
  },
  {
    "path": "tests/fixtures/tag_archive.html",
    "chars": 380,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width="
  },
  {
    "path": "tests/library/assets/meta/robots.txt",
    "chars": 23,
    "preview": "User-Agent: *\nAllow: /\n"
  },
  {
    "path": "tests/library/assets/styles.css",
    "chars": 163,
    "preview": "* {\n    font-family: San system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'O"
  },
  {
    "path": "tests/library/config.py",
    "chars": 576,
    "preview": "import os\n\nBASE_URLS = {\n    \"local\": os.getcwd(),\n    \"production\": \"https://example.com\",\n}\n\nSITE_ENV = os.environ.get"
  },
  {
    "path": "tests/library/hooks.py",
    "chars": 298,
    "preview": "def retrieve_visitor_count(file_name, page_state, _):\n    page_state[\"visitors\"] = 100\n\n    return page_state\n\n\ndef add_"
  },
  {
    "path": "tests/library/pages/_data/books.json",
    "chars": 124,
    "preview": "[\n  {\"title\": \"The Great Gatsby\", \"author\": \"F. Scott Fitzgerald\", \"layout\": \"book-template\", \"slug\": \"the-great-gatsby\""
  },
  {
    "path": "tests/library/pages/_data/reviews.csv",
    "chars": 65,
    "preview": "name,review,star,layout\nJames,An excellent book., 5,reader-review"
  },
  {
    "path": "tests/library/pages/_layouts/book-template.html",
    "chars": 102,
    "preview": "---\nlayout: default\ntitle: \"{{ page.title }}\"\n---\n\n<h1>{{ page.title }}</h1>\n<p>{{ page.author }}</p>\n"
  },
  {
    "path": "tests/library/pages/_layouts/category.html",
    "chars": 197,
    "preview": "---\nlayout: default\ntitle: \"Category Archive\"\n---\n\n<ul>\n    {% for post in page.posts %}\n        <li>\n            <a hre"
  },
  {
    "path": "tests/library/pages/_layouts/date.html",
    "chars": 692,
    "preview": "---\nlayout: default\ntitle: \"Date Archive\"\n---\n{% if page.date_type == \"year\" %}\n  <h1>Posts from {{ page.date | year }}<"
  },
  {
    "path": "tests/library/pages/_layouts/default.html",
    "chars": 269,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width="
  },
  {
    "path": "tests/library/pages/_layouts/post.html",
    "chars": 274,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width="
  },
  {
    "path": "tests/library/pages/_layouts/reader-review.html",
    "chars": 143,
    "preview": "---\nlayout: default\ntitle: \"Review by {{ page.name }}\"\n---\n\n<h1>{{ page.name }}</h1>\n\n<p>{{ page.review }}</p>\n\n<p>{{ pa"
  },
  {
    "path": "tests/library/pages/_layouts/rooms.html",
    "chars": 162,
    "preview": "---\nlayout: default\ntitle: Rooms\n---\n\n<ul>\n    {% for room in page.current_page %}\n        <li>\n            {{ room.titl"
  },
  {
    "path": "tests/library/pages/_layouts/tag.html",
    "chars": 192,
    "preview": "---\nlayout: default\ntitle: \"Tag Archive\"\n---\n\n<ul>\n    {% for post in page.posts %}\n        <li>\n            <a href=\"{{"
  },
  {
    "path": "tests/library/pages/posts/2024-01-01-first-post.md",
    "chars": 119,
    "preview": "---\ntitle: \"Hello, World!\"\nlayout: post\ncategories:\n- Featured\ntags:\n- Announcements\n---\n\nThis is our first blog post.\n"
  },
  {
    "path": "tests/library/pages/rooms/quiet-corner.html",
    "chars": 84,
    "preview": "---\ntitle: Quiet Corner\nlayout: default\ncollection: rooms\n---\n\n<h1>Quiet Corner</h1>"
  },
  {
    "path": "tests/library/pages/rooms/study-hall.html",
    "chars": 80,
    "preview": "---\ntitle: Study Hall\nlayout: default\ncollection: rooms\n---\n\n<h1>Study Hall</h1>"
  },
  {
    "path": "tests/library/pages/templates/about.html",
    "chars": 95,
    "preview": "---\ntitle: About\nlayout: default\n---\n\n<p>We serve {{ page.visitors }} readers every month.</p>\n"
  },
  {
    "path": "tests/library/pages/templates/about_ISO-8859-1.html",
    "chars": 154,
    "preview": "---\ntitle: About ISO Test\nlayout: default\n---\n\n<p>A test of a file encoded with ISO-8859-1.</p>\n\n<p>We serve {{ page.vis"
  },
  {
    "path": "tests/library/pages/templates/about_Windows-1252.html",
    "chars": 166,
    "preview": "---\ntitle: About Windows-1252 Test\nlayout: default\n---\n\n<p>A test of a file encoded with Windows-1252\t.</p>\n\n<p>We serve"
  },
  {
    "path": "tests/library/pages/templates/book_list.html",
    "chars": 269,
    "preview": "---\ntitle: Book List\npermalink: /book-list/\nlayout: default\n---\n\n<h2>Books</h2>\n\n<ul>\n    {% for book in site.books %}\n "
  },
  {
    "path": "tests/library/pages/templates/index.html",
    "chars": 324,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width="
  },
  {
    "path": "tests/library/state.json",
    "chars": 182,
    "preview": "{\"last_build\": \"2024-08-08T10:55:05.167577\", \"data_file_integrity\": {\"0\": \"83cab347b81062bf833f9de2a5f117ee63b02f99\", \"t"
  },
  {
    "path": "tests/state.py",
    "chars": 5539,
    "preview": "import os\nimport shutil\n\nTEST_FOLDER = os.path.join(os.getcwd(), \"tests/library\")\nBASE_SITE_DIRECTORY = os.path.join(TES"
  }
]

// ... and 1 more files (download for full content)

About this extraction

This page contains the full source code of the capjamesg/aurora GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 93 files (167.5 KB), approximately 47.5k tokens, and a symbol index with 68 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!