[
  {
    "path": ".github/ISSUE_TEMPLATE/bug-report.yml",
    "content": "name: Bug Report\ndescription: File a bug with Aurora.\nlabels: [bug]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thank you for submitting a bug report!\n\n  - type: textarea\n    attributes:\n      label: Bug\n      description: Provide a description of the bug you have encountered. If you are running into an error, please include the full error message.\n    validations:\n      required: true\n\n  - type: textarea\n    attributes:\n      label: Minimal Reproducible Example\n      description: >\n        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.\n        This is referred to by community members as creating a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example).\n      placeholder: |\n        ```\n        # Code to reproduce your issue here\n        ```\n    validations:\n      required: false\n\n  - type: textarea\n    attributes:\n      label: Additional\n      description: Anything else you would like to share?\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"daily\"\n  - package-ecosystem: \"pip\"\n    directory: \"/\"\n    schedule:\n      interval: \"daily\"\n"
  },
  {
    "path": ".github/workflows/benchmark.yml",
    "content": "name: Run benchmark (200k pages+)\n\non: workflow_dispatch\n\njobs:\n  build:\n    runs-on: ${{ matrix.os }}\n    strategy:\n      matrix:\n        os: [\"ubuntu-latest\", \"macos-latest\"]\n        python-version: [\"3.13\"]\n\n    steps:\n      - uses: actions/checkout@v6\n      - name: Set up Python ${{ matrix.python-version }}\n        uses: actions/setup-python@v6\n        with:\n          python-version: ${{ matrix.python-version }}\n          cache: 'pip'\n      - name: Install dependencies\n        run: |\n          python3 -m pip install --upgrade pip\n          pip install -e .\n          git clone https://github.com/capjamesg/aurora-hn-benchmark\n      - name: Build main site\n        env:  \n          SITE_ENV: ${{ secrets.SITE_ENV }}  \n        run: |\n          cd aurora-hn-benchmark\n          { time aurora build; } 2> time_output.txt\n          echo \"${{ matrix.os }} - Python ${{ matrix.python-version }}\" > performance.txt\n          echo \"Commit: $(git rev-parse HEAD)\" >> time_taken.txt\n          cat time_output.txt | grep real | awk '{print $2}' >> performance.txt\n          cat performance.txt\n"
  },
  {
    "path": ".github/workflows/docs.yml",
    "content": "name: Publish documentation\n\non:\n  push:\n    branches:\n      - main\n\njobs:\n  build:\n\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        python-version: [3.13]\n\n    steps:\n      - uses: actions/checkout@v6\n      - name: Set up Python ${{ matrix.python-version }}\n        uses: actions/setup-python@v6\n        with:\n          python-version: ${{ matrix.python-version }}\n          cache: 'pip'\n      - name: Install dependencies\n        run: |\n          python3 -m pip install --upgrade pip\n          pip install -e .\n          pip install pygments bs4 lxml\n          cd docs\n      - name: Build main site\n        env:  \n          SITE_ENV: ${{ secrets.SITE_ENV }}  \n        run: |\n          cd docs\n          aurora build\n      - name: rsync deployments\n        uses: burnett01/rsync-deployments@7.1.0\n        with:\n          switches: -avzr\n          path: \"docs/_site/*\"\n          remote_path: ${{ secrets.SITE_PATH }}\n          remote_host: ${{ secrets.SERVER_HOST }}\n          remote_user: ${{ secrets.SERVER_USERNAME }}\n          remote_key: ${{ secrets.KEY }}\n"
  },
  {
    "path": ".github/workflows/full-site-tests.yml",
    "content": "name: Test several sites built with Aurora\n\non:\n  push:\n    branches:\n      - main\n\njobs:\n  build:\n    runs-on: ${{ matrix.os }}\n    strategy:\n      matrix:\n        os: [\"ubuntu-latest\", \"macos-latest\"]\n        python-version: [\"3.9\", \"3.10\", \"3.11\", \"3.12\", \"3.13\"]\n\n    steps:\n      - uses: actions/checkout@v6\n      - name: Set up Python ${{ matrix.python-version }}\n        uses: actions/setup-python@v6\n        with:\n          python-version: ${{ matrix.python-version }}\n          cache: 'pip'\n      - name: Install dependencies\n        run: |\n          python3 -m pip install --upgrade pip\n          pip install -e .\n      - name: Build airport pianos\n        env:  \n          SITE_ENV: ${{ secrets.SITE_ENV }}  \n        run: |\n          git clone https://github.com/capjamesg/airport-pianos\n          cd airport-pianos\n          aurora build\n      - name: Build train station pianos\n        env:  \n          SITE_ENV: ${{ secrets.SITE_ENV }}  \n        run: |\n          git clone https://github.com/capjamesg/train-station-pianos\n          cd train-station-pianos\n          aurora build\n      - name: Build blog example\n        env:  \n          SITE_ENV: ${{ secrets.SITE_ENV }}  \n        run: |\n          git clone https://github.com/capjamesg/aurora-blog-template\n          cd aurora-blog-template\n          aurora build\n      - name: Build docs example\n        env:  \n          SITE_ENV: ${{ secrets.SITE_ENV }}  \n        run: |\n          git clone https://github.com/capjamesg/aurora-docs-template\n          cd aurora-docs-template\n          aurora build\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Publish WorkFlow\n\non:\n  release:\n    types: [created]\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        python-version: [3.8]\n    steps:\n      - name: 🛎️ Checkout\n        uses: actions/checkout@v6\n        with:\n          ref: ${{ github.head_ref }}\n      - name: 🐍 Set up Python ${{ matrix.python-version }}\n        uses: actions/setup-python@v6\n        with:\n          python-version: ${{ matrix.python-version }}\n      - name: 🦾 Install dependencies\n        run: |\n          python -m pip install --upgrade pip twine wheel\n      - name: 🚀 Publish to PyPi\n        env:\n          PYPI_USERNAME: ${{ secrets.PYPI_USERNAME }}\n          PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }}\n        run: |\n          python setup.py sdist bdist_wheel\n          twine check dist/*\n          twine upload dist/* -u ${PYPI_USERNAME} -p ${PYPI_PASSWORD} --verbose\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Aurora Test Suite\n\non:\n  pull_request:\n    branches: [main]\n  push:\n    branches: [main]\n\njobs:\n  build-dev-test:\n    runs-on: ${{ matrix.os }}\n    strategy:\n      matrix:\n        os: [\"ubuntu-latest\", \"macos-latest\"]\n        python-version: [\"3.9\", \"3.10\", \"3.11\", \"3.12\", \"3.13\"]\n    steps:\n      - name: 🛎️ Checkout\n        uses: actions/checkout@v6\n      - name: 🐍 Set up Python ${{ matrix.python-version }}\n        uses: actions/setup-python@v6\n        with:\n          python-version: ${{ matrix.python-version }}\n          check-latest: true\n\n      - name: 📦 Install dependencies\n        run: |\n          python -m pip install --upgrade pip\n          pip install .\n          pip install pytest\n\n      - name: 🧪 Test\n        env:\n          SITE_ENV: production\n        run: \"python -m pytest ./tests/state.py\"\n"
  },
  {
    "path": ".github/workflows/welcome.yml",
    "content": "name: Welcome\n\non: [pull_request, issues]\n\njobs:\n  greeting:\n    name: 👋 Welcome\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/first-interaction@v3\n        with:\n          repo-token: ${{ secrets.GITHUB_TOKEN }}\n          issue-message: \"Thank you for creating an Issue on this repository! 🙌 We will get back to you shortly.\"\n          pr-message: \"Thank you for creating an PR on this repository! 🙌 We will get back to you shortly.\"\n"
  },
  {
    "path": ".gitignore",
    "content": "# env specific\nconfig.json\n.env\n# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n.idea\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\nshare/python-wheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n.gptexecthread\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.nox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n*.py,cover\n.hypothesis/\n.pytest_cache/\ncover/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\nlocal_settings.py\ndb.sqlite3\ndb.sqlite3-journal\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\n.pybuilder/\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# IPython\nprofile_default/\nipython_config.py\n\n# pyenv\n#   For a library or package, you might want to ignore these files since the code is\n#   intended to run in multiple environments; otherwise, check them in:\n# .python-version\n\n# pipenv\n#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.\n#   However, in case of collaboration, if having platform-specific dependencies or dependencies\n#   having no cross-platform support, pipenv may install dependencies that don't work, or not\n#   install all needed dependencies.\n#Pipfile.lock\n\n# PEP 582; used by e.g. github.com/David-OConnor/pyflow\n__pypackages__/\n\n# Celery stuff\ncelerybeat-schedule\ncelerybeat.pid\n\n# SageMath parsed files\n*.sage.py\n\n# Environments\n.env\n.venv\nenv/\nvenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n.dmypy.json\ndmypy.json\n\n# Pyre type checker\n.pyre/\n\n# pytype static type analyzer\n.pytype/\n\n# Cython debug symbols\ncython_debug/\n\n#config stuff\ntests/config.json\ntests/manual/data\n\n#dataset download stuff\n\n# test/\n# train/\n# valid/\n# data.yaml\nREADME.roboflow.txt\n*.zip\n.DS_Store\n_site/*\ndocs/_site/*\ntests/library/_site/*"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe 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).\n\n## [0.0.4] 2024-05-28\n\n* Add support for data files\n\n## [0.0.2] 2024-05-28\n\n* Fix bug where `page` data was not available in parent templates.\n* Add `pages` variable that can be used to see a list of all pages in a template.\n* Small performance improvements.\n* Fix missing `click` dependency.\n\n## [0.0.1] 2024-05-27\n\nInitial release of Aurora.\n"
  },
  {
    "path": "CITATION.cff",
    "content": "# This CITATION.cff file was generated with cffinit.\n# Visit https://bit.ly/cffinit to generate yours today!\n\ncff-version: 1.2.0\ntitle: Aurora\nmessage: >-\n  If you use this software, please cite it using the\n  metadata from this file.\ntype: software\nauthors:\n  - given-names: James\n    email: jamesg@jamesg.blog\nrepository-code: 'https://github.com/capjamesg/aurora'\nurl: 'https://github.com/capjamesg/aurora'\nabstract: >-\n   A static site generator implemented in Python. \nkeywords:\n  - static site generator\n  - website\nlicense: MIT\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2024 James\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE."
  },
  {
    "path": "Makefile",
    "content": ".PHONY: style check_code_quality\n\nexport PYTHONPATH = .\ncheck_dirs := aurora\n\nstyle:\n\tblack  $(check_dirs)\n\tisort --profile black $(check_dirs)\n\ncheck_code_quality:\n\tblack --check $(check_dirs)\n\tisort --check-only --profile black $(check_dirs)\n\t# stop the build if there are Python syntax errors or undefined names\n\tflake8 $(check_dirs) --count --select=E9,F63,F7,F82 --show-source --statistics\n\t# exit-zero treats all errors as warnings. E203 for black, E501 for docstring, W503 for line breaks before logical operators \n\tflake8 $(check_dirs) --count --max-line-length=88 --exit-zero  --ignore=D --extend-ignore=E203,E501,W503  --statistics\n\t\npublish:\n\tpython3 -m build\n\ttwine check dist/*\n\ttwine upload dist/* -u ${PYPI_USERNAME} -p ${PYPI_PASSWORD} --verbose "
  },
  {
    "path": "README.md",
    "content": "![Banner](banner.png)\n\n<div align=\"center\">\n\n[![version](https://badge.fury.io/py/aurora-ssg.svg)](https://badge.fury.io/py/aurora-ssg)\n[![downloads](https://img.shields.io/pypi/dm/aurora-ssg)](https://pypistats.org/packages/aurora-ssg)\n[![license](https://img.shields.io/pypi/l/aurora-ssg)](https://github.com/capjamesg/aurora-ssg/blob/main/LICENSE.md)\n[![python-version](https://img.shields.io/pypi/pyversions/aurora-ssg)](https://badge.fury.io/py/aurora-ssg)\n[![test workflow](https://github.com/capjamesg/aurora/actions/workflows/test.yml/badge.svg)](https://github.com/capjamesg/aurora/actions/workflows/test.yml)\n</div>\n\n# Aurora\n\nAurora is a static site generator implemented in Python.\n\n[See a blog template that you can use with Aurora](https://github.com/capjamesg/aurora-blog-template).\n\nAurora supports:\n\n- Creating content and pages with markdown, jinja2, and HTML\n- Static and incremental builds\n- Interactive building with hot reloading for development (up to < 300ms reload time)\n- Out-of-the-box support for generating date, category, and tag archive pages\n\nAurora 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.\n\n## Demos\n\n### Static Generation (1k+ pages)\n\nhttps://github.com/capjamesg/aurora/assets/37276661/59e4f3e6-f470-46bd-8812-0b475be40e88\n\n### Incremental Static Regeneration (~40 pages)\n\nhttps://github.com/capjamesg/aurora/assets/37276661/39f62bd8-cf5f-4d15-a325-7d433b7ceeb0\n\n## Get Started\n\n### Install Aurora\n\nFirst, install Aurora:\n\n```bash\npip3 install aurora-ssg\n```\n\n### Create a Site\n\nTo create a new site, run the following command:\n\n```bash\naurora new my-site\n```\n\nThis will create a folder called `my-site` with everything you need to start your Aurora site.\n\nTo navigate to your site, run:\n\n```bash\ncd my-site\n```\n\nAurora sites contain a few directories by default:\n\n- `_layouts`: Store templates for your site.\n- `assets`: Store static files like images, CSS, and JavaScript.\n- `posts`: Store blog posts (optional).\n- `pages`: Store static pages to generate.\n\nA new Aurora site will come with a `pages/index.html` file that you can edit to get started.\n\n### Build Your Site (Static)\n\nYou can build your site into a static site by running the `aurora build` command.\n\nAurora works relative to the directory you are in.\n\nTo build your site, navigate run the following command:\n\n```bash\naurora build\n```\n\nThis will generate your site in a `_site` directory.\n\n### Build Your Site (Dynamic)\n\nFor 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.\n\nTo run Aurora in watch mode, run the following command:\n\n```bash\naurora serve\n```\n\nYour 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.\n\n### Development Setup\n\nIf you are interested in contributing to Aurora, you will need a local development setup.\n\nTo set up your development environment, run the following commands:\n\n```bash\ngit clone https://github.com/capjamesg/aurora\ncd aurora\npip3 install -e .\n```\n\nThis will install Aurora in editable mode. In editable mode, you can make changes to the code and see them reflected in your local installation.\n\n## Aurora Site Structure\n\nBy default, an Aurora site has the following structure in the root directory:\n\n- `pages`: Where all pages used to generate your site are stored.\n- `pages/_layouts`: Where you can store layouts for use in generating your website.\n- `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.\n- `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.\n\nAny 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`.\n\n## Configuration\n\nYou 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]`.\n\nThis configuration file defines a few values that Aurora will use when processing your website.\n\nHere is the default `config.py` file, with accompanying comments:\n\n```python\nimport os\n\nBASE_URLS = {\n    \"local\": os.getcwd(),\n}\n\nSITE_ENV = os.environ.get(\"SITE_ENV\", \"local\")\nBASE_URL = BASE_URLS[SITE_ENV]\nROOT_DIR = \"pages\" # where your site pages are\nLAYOUTS_BASE_DIR = \"_layouts\" # where your site layouts are stored\nSITE_DIR = \"_site\" # the directory in which your site will be saved\nREGISTERED_HOOKS = {} # used to register hooks (see `Build Hooks (Advanced)` documentation below for details)\n```\n\nThe `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).\n\nHere is an example configuration of a site that has a local and staging environment:\n\n```python\nBASE_URLS = {\n    \"production\": \"https://jamesg.blog\",\n    \"staging\": \"https://staging.jamesg.blog\",\n    \"local\": os.getcwd(),\n}\n```\n\n## Render Collections of Data\n\nYou 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.\n\nTo create a collection, add a new file to your site's `pages/_data` directory. This file should have a `.json` extension.\n\nWithin the file, create a list that contains JSON objects, like this:\n\n```json\n[\n    {\"slug\": \"rosslyn-coffee\", \"layout\": \"coffee\", \"title\": \"Rosslyn Coffee in London is terrific.\"}\n]\n```\n\nThis file is called `pages/_data/coffee.json`.\n\nEvery 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.\n\nWe 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:\n\n```\n---\ntitle: Coffee List\n---\n\n{% for item in site.coffee %}\n    {{ item.title }}\n{% endfor %}\n```\n\nEvery 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`.\n\n## Build Hooks (Advanced)\n\nYou 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.\n\nThese functions are called \"hooks\".\n\nTo define a hook, you need to:\n\n1. Write a hook function with the right type signature, and;\n2. Add the hook function to the `HOOKS` dictionary in your `config.py` file.\n\nFor example, you could define a function that saves the word count of a page:\n\n```python\ndef word_count_hook(file_name: str, page_state: dict, site_state: dict):\n    if \"posts/\" not in file_name:\n        return page_state\n\n    page_state[\"word_count\"] = len(page_state[\"content\"].split())\n    return page_state\n```\n\nSuppose this is saved in a file called `hooks.py`.\n\nThis function would make a `page.word_count` available in the page on which it is run.\n\nHooks **must** return the `page_state` dictionary, otherwise the page cannot be processed correctly.\n\nTo register a hook, create an entry in the `REGISTERED_HOOKS` dictionary in your `config.py` file:\n\n```python\nREGISTERED_HOOKS = {\n    \"hooks\": [\"word_count_hook\"],\n}\n```\n\nAbove, `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`.\n\nYou can define as many hooks as you want.\n\nTo register multiple hooks in the same file, use the syntax:\n\n```python\nREGISTERED_HOOKS = {\n    \"hook_file_name\": [\"hook1\", \"hook2\", \"hook3\"],\n}\n```\n\n## Test Suite\n\nTo run the Aurora tests, run:\n\n```\npytest tests/*.py\n```\n\n## Performance\n\nIn 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).\n\nIn 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).\n\nIn 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.\n\nIn 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.\n\n## Users\n\nThe following sites are built with Aurora:\n\n- [James' Coffee Blog](https://jamesg.blog) (1,500+ pages)\n- [Airport Pianos](https://airportpianos.org) (~45 pages)\n- [Train Station Pianos](https://trainstationpianos.org) (~20 pages)\n\nHave you made a website with Aurora? File a PR and add it to the list!\n\n## License\n\nThis project is licensed under an [MIT license](LICENSE).\n"
  },
  {
    "path": "aurora/__init__.py",
    "content": "__version__ = \"0.1.7\"\n"
  },
  {
    "path": "aurora/cli.py",
    "content": "import os\n\nimport click\n\nfrom . import __version__\n\n\n@click.group()\n@click.version_option(version=__version__)\ndef main():\n    pass\n\n\n@click.command(\"new\")\n@click.argument(\"name\")\ndef new(name):\n    cli_dir = os.path.dirname(os.path.realpath(__file__))\n    if os.path.exists(name):\n        print(\"Site already exists.\")\n        return\n\n    os.makedirs(name)\n    os.chdir(name)\n\n    with open(\"config.py\", \"w\") as f:\n        f.write(\n            \"\"\"import os\n\nBASE_URLS = {\n    \"local\": os.getcwd(),\n    \"production\": \"https://example.com\",\n}\n\nSITE_ENV = os.environ.get(\"SITE_ENV\", \"local\")\nBASE_URL = BASE_URLS[SITE_ENV]\nROOT_DIR = \"pages\"\nLAYOUTS_BASE_DIR = \"_layouts\"\nSITE_DIR = \"_site\"\nHOOKS = {}\nSITE_STATE = {}\n\"\"\"\n        )\n\n    os.makedirs(\"pages\")\n    os.makedirs(\"assets\")\n    os.chdir(\"pages\")\n    os.makedirs(\"_layouts\")\n    os.makedirs(\"_data\")\n    os.makedirs(\"posts\")\n    os.makedirs(\"templates/\")\n\n    with open(\"templates/index.html\", \"w\") as f:\n        with open(os.path.join(cli_dir, \"templates\", \"index.html\")) as index:\n            f.write(index.read())\n\n    os.chdir(\"..\")\n\n    print(f\"Site {name} created. ✨\")\n    print(\"Run cd/into the site directory.\")\n    print(\"Then, `aurora build` to build the site.\")\n    print(\"You can also `aurora serve` to start a local server.\")\n\n\n@click.command(\"build\")\n@click.option(\"--incremental\", is_flag=True)\ndef build(incremental):\n    from .graph import main as build_site\n\n    # import cProfile\n    # cProfile.run(\"build_site(incremental=incremental)\", sort=\"cumulative\")\n    print(\"Building site...\")\n    build_site(incremental=incremental)\n    print(\"Done! ✨\")\n\n\n@click.command(\"serve\")\ndef serve():\n    from .graph import main as build_site\n\n    build_site(watch=True)\n\n\nmain.add_command(new)\nmain.add_command(build)\nmain.add_command(serve)\n"
  },
  {
    "path": "aurora/date_helpers.py",
    "content": "import datetime\n\nimport dateutil.parser\n\n\ndef month_number_to_written_month(month):\n    return datetime.datetime.strptime(str(month), \"%m\").strftime(\"%B\")\n\n\ndef list_archive_date(date):\n    if isinstance(date, str):\n        date = dateutil.parser.parse(date)\n\n    return date\n\n\ndef long_date(date):\n    return list_archive_date(date).strftime(\"%B %d, %Y\")\n\n\ndef date_to_xml_string(date):\n    return list_archive_date(date).strftime(\"%Y-%m-%dT%H:%M:%S\")\n\n\ndef archive_date(date):\n    return list_archive_date(date).strftime(\"%Y/%m\")\n\n\ndef year(date):\n    return list_archive_date(date).strftime(\"%Y\")\n"
  },
  {
    "path": "aurora/graph.py",
    "content": "import logging\nimport os\nimport sys\n\nif not os.path.exists(\"config.py\"):\n    raise Exception(\"config.py not found\")\n\nimport csv\nimport datetime\nimport hashlib\nimport json\nimport re\nfrom copy import deepcopy\n\nimport chardet\nimport orjson\nimport pyromark\nimport tqdm\nfrom frontmatter import loads\nfrom bs4 import BeautifulSoup\nfrom jinja2 import (\n    Environment,\n    FileSystemBytecodeCache,\n    FileSystemLoader,\n    Template,\n    meta,\n    nodes,\n)\nfrom jinja2.visitor import NodeVisitor\nfrom toposort import toposort_flatten\nfrom yaml.reader import ReaderError\nfrom collections import defaultdict\n\nfrom .date_helpers import (\n    archive_date,\n    date_to_xml_string,\n    list_archive_date,\n    long_date,\n    month_number_to_written_month,\n    year,\n)\n\nmodule_dir = os.getcwd()\nos.chdir(module_dir)\nsys.path.append(module_dir)\nstate_to_write = {}\noriginal_file_to_permalink = {}\nnormalized_collection_permalinks = {}\n\n# print all logs\nlogging.basicConfig(level=logging.INFO)\n\nfrom config import (\n    BASE_URL,\n    HOOKS,\n    LAYOUTS_BASE_DIR,\n    ROOT_DIR,\n    SITE_DIR,\n    SITE_STATE,\n    SITE_ENV,\n)\n\nALLOWED_EXTENSIONS = [\"html\", \"md\", \"css\", \"js\", \"txt\", \"xml\"]\n\nsaved_pages = set()\npermalinks = defaultdict(list)\nall_data_files = {}\nall_pages = []\nall_opened_pages = {}\nall_page_contents = {}\ncollections_to_files = {}\nall_dependencies = {}\nall_parsed_pages = {}\ndates = set()\nyears = {}\nreverse_deps = {}\ncollection_permalinks_to_idx = {}\nlayout_permalinks_to_idx = {}\n\n# ensures a single template cannot have more than 10 levels of inheritance\nINHERITANCE_LIMIT = 10\n\nDATA_FILES_DIR = os.path.join(ROOT_DIR, \"_data\")\n\nEVALUATED_REGISTERED_TEMPLATE_GENERATION_HOOKS = {}\nEVALUATED_POST_TEMPLATE_GENERATION_HOOKS = {}\nEVALUATED_POST_BUILD_HOOKS = {}\n\n\nclass Post:\n    def __init__(self, front_matter):\n        self.__dict__.update(front_matter)\n\n    def __getattr__(self, name):\n        return self.__dict__.get(name)\n\n    def serialize_as_json(self):\n        \"\"\"Serialize the Post object as JSON.\"\"\"\n        return orjson.dumps(self.__dict__).decode()\n\n\nfor file_name, hooks in HOOKS.get(\"pre_template_generation\", {}).items():\n    EVALUATED_REGISTERED_TEMPLATE_GENERATION_HOOKS[file_name] = [\n        getattr(__import__(file_name), func) for func in hooks\n    ]\n\nfor file_name, hooks in HOOKS.get(\"post_template_generation\", {}).items():\n    EVALUATED_POST_TEMPLATE_GENERATION_HOOKS[file_name] = [\n        getattr(__import__(file_name), func) for func in hooks\n    ]\n\nfor file_name, hooks in HOOKS.get(\"post_build\", {}).items():\n    EVALUATED_POST_BUILD_HOOKS[file_name] = [\n        getattr(__import__(file_name), func) for func in hooks\n    ]\n\ntoday = datetime.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)\nstate = {\n    \"posts\": [],\n    \"backlinks\": defaultdict(list),\n    \"root_url\": BASE_URL,\n    \"build_date\": today.strftime(\"%m-%d\"),\n    \"pages\": [],\n    \"build_timestamp\": datetime.datetime.now().isoformat(),\n    \"environment\": SITE_ENV,\n}\n\nfile_extensions = {}\n\nstate.update(SITE_STATE)\n\nJINJA2_ENV = Environment(\n    loader=FileSystemLoader(ROOT_DIR),\n    bytecode_cache=FileSystemBytecodeCache(),\n)\n\n\nJINJA2_ENV.filters[\"long_date\"] = long_date\nJINJA2_ENV.filters[\"date_to_xml_string\"] = date_to_xml_string\nJINJA2_ENV.filters[\"archive_date\"] = archive_date\nJINJA2_ENV.filters[\"list_archive_date\"] = list_archive_date\nJINJA2_ENV.filters[\"month_number_to_written_month\"] = month_number_to_written_month\nJINJA2_ENV.filters[\"year\"] = year\n\nfor file_name, hooks in HOOKS.get(\"template_filters\", {}).items():\n    for hook in hooks:\n        JINJA2_ENV.filters[hook] = getattr(__import__(file_name), hook)\n\nmd = pyromark.Markdown(\n    options=(\n        pyromark.Options.ENABLE_FOOTNOTES\n        | pyromark.Options.ENABLE_SMART_PUNCTUATION\n        | pyromark.Options.ENABLE_HEADING_ATTRIBUTES\n    )\n)\n\n\ndef read_file(file_name, mode=\"r\") -> str:\n    \"\"\"\n    Read a file and return its contents.\n    \"\"\"\n    try:\n        with open(file_name, mode) as file:\n            return file.read()\n    except UnicodeDecodeError as e:\n        raw_data = open(file_name, \"rb\").read()\n        result = chardet.detect(raw_data)\n        encoding = result[\"encoding\"]\n\n        with open(file_name, \"rb\") as file:\n            return file.read().decode(encoding)\n    except Exception as e:\n        print(f\"Error reading {file_name}\")\n        raise e\n\n\ndef slugify(value: str) -> str:\n    \"\"\"\n    Turn a string into a slug for use in saving data to a file.\n    \"\"\"\n    return value.lower().strip().replace(\" \", \"-\")\n\n\nclass VariableVisitor(NodeVisitor):\n    \"\"\"\n    Find all variables in a jinja2 template.\n    \"\"\"\n\n    def __init__(self):\n        self.variables = set()\n\n    def visit_Name(self, node, *args, **kwargs) -> None:\n        self.variables.add(node.name)\n        self.generic_visit(node, *args, **kwargs)\n\n    def visit_Getattr(self, node, *args, **kwargs) -> None:\n        current_node = node\n        variable_chain = []\n        while isinstance(current_node, nodes.Getattr):\n            variable_chain.append(current_node.attr)\n            current_node = current_node.node\n        if isinstance(current_node, nodes.Name):\n            variable_chain.append(current_node.name)\n        full_variable = \".\".join(reversed(variable_chain))\n        self.variables.add(full_variable)\n        self.generic_visit(node, *args, **kwargs)\n\n\ndef get_file_dependencies_and_evaluated_contents(\n    file_name: str, contents: Template\n) -> tuple:\n    \"\"\"\n    Get all dependencies of a file. Dependencies are:\n\n    1. Other files that are included in the file, and;\n    2. Variables whose values are defined by the site generator (i.e. `site.*`).\n    \"\"\"\n    template = JINJA2_ENV.parse(all_page_contents[file_name])\n\n    includes = []\n    included_variables = []\n\n    for node in meta.find_referenced_templates(template):\n        includes.append(node)\n\n    visitor = VariableVisitor()\n    visitor.visit(template)\n\n    for var in visitor.variables:\n        included_variables.append(var)\n\n    dependencies = set()\n\n    for include in includes:\n        if isinstance(include, str):\n            dependencies.add(os.path.join(ROOT_DIR, include))\n        else:\n            dependencies.add(os.path.join(ROOT_DIR, include.template.value))\n\n    for variable in included_variables:\n        variable = variable.replace(\"site.\", \"\")\n\n        for collection in collections_to_files:\n            if collections_to_files.get(collection):\n                dependencies.update(collections_to_files[collection])\n\n        if variable in state:\n            dependencies.add(variable)\n\n    parsed_content = all_page_contents[file_name]\n\n    if not parsed_content.get(\"slug\"):\n        parsed_content[\"slug\"] = file_name.split(\"/\")[-1].replace(\".html\", \"\")\n\n    parsed_content[\"contents\"] = md.html(parsed_content.content)\n\n    parsed_content[\"url\"] = f\"{BASE_URL}/{file_name.replace(ROOT_DIR + '/posts/', '')}\"\n\n    if parsed_content.metadata.get(\"permalink\") and parsed_content.metadata[\"permalink\"].startswith(\"/\"):\n        parsed_content[\"has_user_assigned_permalink\"] = True\n        parsed_content[\"permalink\"] = f\"/{parsed_content.metadata['permalink'].strip('/')}/\"\n\n    parsed_content[\n        \"permalink\"\n    ] = f\"/{parsed_content.metadata.get('permalink', parsed_content['slug']).strip('/')}/\"\n\n    if \"categories\" not in parsed_content:\n        parsed_content[\"categories\"] = []\n\n    slug = file_name.split(\"/\")[-1].replace(\".html\", \"\")\n\n    slug = slug.replace(\"posts/\", \"\")\n\n    if slug[0].isdigit():\n        date_slug = re.search(r\"\\d{4}-\\d{2}-\\d{2}\", slug)\n\n        if date_slug:\n            date_slug = date_slug.group(0)\n            if not parsed_content.get(\"post\"):\n                parsed_content[\"post\"] = {}\n            if not parsed_content.get(\"page\"):\n                parsed_content[\"page\"] = {}\n            parsed_content[\"post\"][\"date\"] = datetime.datetime.strptime(\n                date_slug, \"%Y-%m-%d\"\n            )\n            # if \"hms\" in post metadata, add to post date\n            if parsed_content.get(\"hms\") and parsed_content[\"hms\"].count(\":\") == 2:\n                parsed_content[\"post\"][\"date\"] = parsed_content[\"post\"][\"date\"].replace(\n                    hour=int(parsed_content[\"hms\"].split(\":\")[0]),\n                    minute=int(parsed_content[\"hms\"].split(\":\")[1]),\n                    second=int(parsed_content[\"hms\"].split(\":\")[2]),\n                )\n\n            parsed_content[\"post\"][\"date_without_year\"] = parsed_content[\"post\"][\n                \"date\"\n            ].strftime(\"%m-%d\")\n            parsed_content[\"date_without_year\"] = parsed_content[\"post\"][\n                \"date_without_year\"\n            ]\n\n            parsed_content[\"post\"][\"full_date\"] = parsed_content[\"post\"][\n                \"date\"\n            ].strftime(\"%B %d, %Y\")\n            parsed_content[\"date\"] = parsed_content[\"post\"][\"date\"]\n\n            parsed_content[\"page\"][\"date\"] = parsed_content[\"post\"][\"date\"]\n\n            if \"description\" not in parsed_content:\n                parsed_content[\"description\"] = md.html(\n                    parsed_content.content.split(\"\\n\")[0]\n                )\n            date_slug = date_slug.replace(\"-\", \"/\")\n            slug_without_date = re.sub(r\"\\d{4}-\\d{2}-\\d{2}-\", \"\", slug)\n\n            parsed_content[\"post\"][\n                \"url\"\n            ] = f\"{BASE_URL}/{date_slug}/{slug_without_date.replace('.html', '').replace('.md', '')}/\"\n\n    if \"layout\" in parsed_content:\n        dependencies.add(\n            f\"{ROOT_DIR}/{LAYOUTS_BASE_DIR}/{parsed_content['layout']}.html\"\n        )\n        if not state.get(parsed_content[\"layout\"] + \"s\"):\n            state[parsed_content[\"layout\"] + \"s\"] = []\n\n        if not layout_permalinks_to_idx.get(parsed_content[\"permalink\"]):\n            state[parsed_content[\"layout\"] + \"s\"].append(parsed_content)\n            layout_permalinks_to_idx[parsed_content[\"permalink\"]] = (\n                len(state[parsed_content[\"layout\"] + \"s\"]) - 1\n            )\n        else:\n            if (\n                len(state[parsed_content[\"layout\"] + \"s\"])\n                > layout_permalinks_to_idx[parsed_content[\"permalink\"]]\n            ):\n                state[parsed_content[\"layout\"] + \"s\"][\n                    layout_permalinks_to_idx[parsed_content[\"permalink\"]]\n                ] = parsed_content\n\n    if \"collection\" in parsed_content:\n        collection_normalized = parsed_content[\"collection\"].lower()\n        if not state.get(collection_normalized):\n            state[collection_normalized] = []\n\n        if not normalized_collection_permalinks.get(collection_normalized):\n            normalized_collection_permalinks[collection_normalized] = []\n\n        # if permalink in collection_permalinks_to_idx, replace\n        if collection_permalinks_to_idx.get(parsed_content[\"permalink\"]):\n            state[collection_normalized][\n                collection_permalinks_to_idx[parsed_content[\"permalink\"]]\n            ] = parsed_content\n        else:\n            state[collection_normalized].append(parsed_content)\n\n        collection_permalinks_to_idx[parsed_content[\"permalink\"]] = state[\n            collection_normalized\n        ].index(parsed_content)\n\n    return dependencies, parsed_content\n\n\ndef make_any_nonexistent_directories(path: str) -> None:\n    if not os.path.exists(path):\n        os.makedirs(path)\n\n\ndef interpolate_front_matter(front_matter: dict, state: dict, runtime = None) -> dict:\n    \"\"\"Evaluate front matter with Jinja2 to allow logic in front matter.\"\"\"\n    # Keep track of already interpolated keys to prevent double interpolation\n    interpolated_keys = set()\n    \n    for key in front_matter.keys():\n        if (\n            isinstance(front_matter[key], str)\n            and \"{\" in front_matter[key]\n            and key != \"contents\"\n            and (not front_matter.get(\"defer_title_evaluation\") or runtime == \"category\")\n            and key not in interpolated_keys  # Only interpolate if key hasn't been processed\n        ):\n            try:\n                front_matter[key] = JINJA2_ENV.from_string(front_matter[key]).render(\n                    page=front_matter.get(\"page\", front_matter), site=state\n                )\n                interpolated_keys.add(key)  # Mark this key as interpolated\n            except:\n                print(f\"Error evaluating {front_matter[key]}. ERROR.\")\n                continue\n\n    return front_matter\n\n\ndef recursively_build_page_template_with_front_matter(\n    file_name: str,\n    front_matter: dict,\n    state: dict,\n    current_contents: str = \"\",\n    level: int = 0,\n    original_page: dict = None,\n) -> str:\n    \"\"\"\n    Recursively build a page template with front matter.\n\n    This function is called recursively until there is no layout key in the front matter.\n    \"\"\"\n    if level > 10:\n        logging.critical(\n            f\"{file_name} has more than ten levels of recursion. Template will be marked as empty.\"\n        )\n        return \"\"\n\n    if front_matter and \"layout\" in front_matter.metadata:\n        layout = front_matter.metadata[\"layout\"]\n        layout_path = f\"{ROOT_DIR}/{LAYOUTS_BASE_DIR}/{layout}.html\"\n\n        # Always use a deep copy of the current front matter for this recursion\n        current_page_metadata = deepcopy(front_matter.metadata)\n\n        # Interpolate front matter before creating page object\n        current_page_metadata = interpolate_front_matter(current_page_metadata, state)\n\n        # Create page object with interpolated front matter\n        page_fm = type(\"Page\", (object,), current_page_metadata)()\n\n        current_contents = loads(\n            all_opened_pages[layout_path].render(\n                page=page_fm,\n                site=state,\n                content=current_contents,\n                post=Post(current_page_metadata),\n            )\n        ).content\n\n        layout_front_matter = all_parsed_pages[layout_path]\n\n        # Pass the current page metadata for the next recursion\n        layout_front_matter[\"page\"] = current_page_metadata\n        layout_front_matter[\"post\"] = current_page_metadata\n\n        return recursively_build_page_template_with_front_matter(\n            file_name, layout_front_matter, state, current_contents.strip(), level + 1, current_page_metadata\n        )\n\n    return current_contents\n\n\ndef render_page(file: str, skip_hooks=False) -> None:\n    \"\"\"\n    Render a page with the Aurora static site generator.\n    \"\"\"\n\n    original_file = file\n\n    # # skip if json\n    if file.endswith(\".json\"):\n        return\n\n    try:\n        contents = all_opened_pages[file]\n    except Exception as e:\n        print(f\"Error reading {file}\")\n        # raise e\n        return\n\n    page_state = state.copy()\n\n    has_user_assigned_permalink = False\n\n    if all_parsed_pages[file].get(\"skip\"):\n        return\n\n    slug = file.split(\"/\")[-1].replace(\".html\", \"\")\n\n    slug = slug.replace(\"posts/\", \"\")\n\n    has_user_assigned_permalink = all_parsed_pages[file].metadata.get(\n        \"has_user_assigned_permalink\"\n    )\n\n    page_state[\"page\"] = all_parsed_pages[file].metadata\n    page_state[\"post\"] = all_parsed_pages[file].metadata\n    \n    if not page_state[\"page\"].get(\"permalink\"):\n        page_state[\"page\"][\"permalink\"] = page_state.get(\"permalink\", slug)  # .strip(\"/\")\n\n    page_state[\"page\"][\"generated_on\"] = datetime.datetime.now()\n\n    if slug[0].isdigit():\n        date_slug = re.search(r\"\\d{4}-\\d{2}-\\d{2}\", slug)\n        if date_slug:\n            date_slug = date_slug.group(0)\n            page_state[\"post\"][\"date\"] = datetime.datetime.strptime(\n                date_slug, \"%Y-%m-%d\"\n            )\n            if page_state[\"post\"].get(\"hms\") and page_state[\"post\"][\"hms\"].count(\":\") == 2:\n                page_state[\"post\"][\"date\"] = page_state[\"post\"][\"date\"].replace(\n                    hour=int(page_state[\"post\"][\"hms\"].split(\":\")[0]),\n                    minute=int(page_state[\"post\"][\"hms\"].split(\":\")[1]),\n                    second=int(page_state[\"post\"][\"hms\"].split(\":\")[2]),\n                )\n            page_state[\"post\"][\"full_date\"] = page_state[\"post\"][\"date\"].strftime(\n                \"%B %d, %Y\"\n            )\n            page_state[\"date\"] = page_state[\"post\"][\"date\"]\n            page_state[\"full_date\"] = page_state[\"post\"][\"full_date\"]\n            if \"description\" not in page_state[\"post\"]:\n                page_state[\"post\"][\"description\"] = all_parsed_pages[\n                    file\n                ].content.split(\"\\n\")[0]\n        page_state[\"is_article\"] = True\n\n    if page_state.get(\"date\"):\n        date = page_state[\"date\"]\n        slug = re.sub(r\"\\d{4}-\\d{2}-\\d{2}-\", \"\", file)\n        slug = slug.replace(\"pages/posts/\", \"\").replace(\".md\", \"\").replace(\".html\", \"\")\n        page_state[\"page\"][\"slug\"] = slug\n        page_state[\"page\"][\"url\"] = f\"{BASE_URL}/{date.strftime('%Y/%m/%d')}/{slug}/\"\n    else:\n        page_state[\"page\"][\"url\"] = f\"{BASE_URL}/{slug}/\"\n\n    page_state[\"url\"] = page_state[\"page\"][\"url\"]\n\n    if file == \"pages/templates/index.html\":\n        page_state[\"url\"] = BASE_URL\n        page_state[\"page\"][\"url\"] = BASE_URL\n        page_state[\"page\"][\"permalink\"] = BASE_URL\n\n    if not page_state.get(\"categories\"):\n        page_state[\"categories\"] = []\n\n    state[\"categories\"] = []\n\n    page_state[\"page\"][\"generated_from\"] = file\n\n    if page_state.get(\"page\"):\n        page_state[\"page\"] = type(\"Page\", (object,), page_state[\"page\"])()\n        page_state[\"post\"] = Post(page_state[\"page\"].__dict__)\n\n    for hook, hooks in EVALUATED_REGISTERED_TEMPLATE_GENERATION_HOOKS.items():\n        for hook in hooks:\n            page_state = hook(file, page_state, state)\n\n    try:\n        if file.endswith(\".md\"):\n            contents = md.html(loads(all_opened_pages[file]).content)\n        elif isinstance(contents, str):\n            # this happens for data files only, where content does not exist\n            contents = \"\"\n        else:\n            contents = loads(contents.render(page=page_state, site=state)).content\n    except Exception as e:\n        # print(f\"Error rendering {file}\")\n        return\n\n    page_state[\"page\"].template = file\n\n    rendered = recursively_build_page_template_with_front_matter(\n        file, all_parsed_pages[file], page_state, contents\n    )\n\n    if not skip_hooks:\n        for _, hooks in EVALUATED_POST_TEMPLATE_GENERATION_HOOKS.items():\n            for hook in hooks:\n                rendered = hook(file, page_state, state, rendered)\n\n    file = file.replace(ROOT_DIR + \"/\", \"\")\n\n    if page_state.get(\"date\"):\n        file = os.path.join(date.strftime(\"%Y/%m/%d\"), f\"{slug}\", \"index.html\")\n\n    if file.endswith(\".md\"):\n        file = file[:-3] + \".html\"\n\n    permalink = file\n\n    # if permalink is _site/templates/index.html, make it _site/index.html\n    if file == \"templates/index.html\":\n        path = os.path.join(SITE_DIR, \"index.html\")\n        if os.path.exists(path):\n            os.remove(path)\n        with open(path, \"w\") as f:\n            f.write(rendered)\n\n        return\n\n    if file.startswith(\"templates/\") and any(\n        file.endswith(ext) for ext in [\".html\", \".md\"]\n    ):\n        if hasattr(page_state[\"page\"], \"permalink\"):\n            permalink = os.path.join(\n                page_state[\"page\"].permalink.strip(\"/\"), \"index.html\"\n            )\n        else:\n            permalink = file.replace(\"templates/\", \"\")\n    elif has_user_assigned_permalink:\n        permalink = os.path.join(page_state[\"page\"].permalink.strip(\"/\"), \"index.html\")\n    else:\n        permalink = file.replace(\"templates/\", \"\")\n\n    permalink_without_index = permalink.split(\"index.html\")[0]\n    final_url = f\"{BASE_URL}/{permalink_without_index.rstrip('/')}/\"\n\n    if final_url not in saved_pages:\n        # if has collections\n        state[\"pages\"].append(\n            {\n                \"url\": final_url,\n                \"file\": file,\n                \"rendered_html\": contents,\n                \"noindex\": True\n                if hasattr(page_state.get(\"page\"), \"noindex\")\n                else False,\n                \"private\": True\n                if hasattr(page_state.get(\"page\"), \"private\")\n                else False,\n                \"title\": (\n                    page_state[\"page\"].title\n                    if page_state.get(\"page\") and hasattr(page_state[\"page\"], \"title\")\n                    else \"\"\n                ),\n                \"collections\": (\n                    page_state[\"page\"].collections\n                    if hasattr(page_state[\"page\"], \"collections\")\n                    else \"\"\n                ),\n                \"modified\": os.path.getctime(original_file) if os.path.exists(original_file) else 0,\n            }\n        )\n        saved_pages.add(final_url)\n\n    permalinks[permalink].append(file)\n\n    permalink = os.path.join(SITE_DIR, permalink)\n\n    if permalink.endswith(\".html\"):\n        make_any_nonexistent_directories(os.path.dirname(permalink))\n    else:\n        make_any_nonexistent_directories(os.path.join(SITE_DIR))\n\n    state_to_write[permalink] = rendered\n    original_file_to_permalink[permalink] = original_file\n\n\ndef generate_date_page_given_year_month_date(\n    ymd_slug, posts, current_date_of_archive, granularity\n) -> None:\n    ymd_path = os.path.join(SITE_DIR, ymd_slug)\n\n    make_any_nonexistent_directories(ymd_path)\n\n    date_archive_layout = f\"{ROOT_DIR}/{LAYOUTS_BASE_DIR}/date.html\"\n\n    if not all_opened_pages.get(date_archive_layout):\n        return\n\n    date_archive_contents = all_opened_pages[date_archive_layout]\n\n    date_archive_state = state.copy()\n    date_archive_state[\"date\"] = current_date_of_archive\n\n    page = deepcopy(all_parsed_pages[date_archive_layout])\n    page[\"date\"] = current_date_of_archive\n    date_archive_state[\"date_type\"] = granularity\n\n    date_archive_state[\"posts\"] = [all_parsed_pages[post].metadata for post in posts]\n\n    # order by date\n    date_archive_state[\"posts\"] = sorted(\n        date_archive_state[\"posts\"],\n        key=lambda x: x[\"date\"],\n        reverse=True,\n    )\n\n    fm = interpolate_front_matter(page, date_archive_state)\n\n    rendered_page = date_archive_contents.render(\n        date_archive_state,\n        site=state,\n        posts=date_archive_state[\"posts\"],\n        page=date_archive_state,\n    )\n\n    if not date_archive_state.get(\"page\"):\n        date_archive_state[\"page\"] = {}\n\n    date_archive_state[\"page\"][\"template\"] = date_archive_layout\n\n    rendered_page = recursively_build_page_template_with_front_matter(\n        ymd_path, fm, date_archive_state, loads(rendered_page).content\n    )\n\n    with open(\n        os.path.join(ymd_path, \"index.html\"),\n        \"wb\",\n        buffering=500,\n    ) as f:\n        f.write(rendered_page.encode())\n\n\ndef generate_paginated_page_for_collection(\n    collection: str, per_page: int, template: str\n) -> None:\n    \"\"\"\n    Generate paginated pages for a collection.\n    \"\"\"\n\n    if not state.get(collection):\n        return\n\n    print(f\"Generating paginated pages for {collection}\")\n\n    collection = state[collection]\n\n    if not collection:\n        return\n\n    all_keys_contain_dates = all(i.metadata.get(\"date\") for i in collection)\n\n    # if all keys have dates\n    if all_keys_contain_dates:\n        collection = sorted(\n            collection, key=lambda x: x.metadata.get(\"date\"), reverse=True\n        )\n    else:\n        collection = sorted(\n            collection, key=lambda x: x.metadata.get(\"title\"), reverse=True\n        )\n\n    for i in tqdm.tqdm(range(0, len(collection), per_page)):\n        page = i // per_page + 1\n        paginated_collection = collection[i : i + per_page]\n\n        print(f\"Generating paginated page {page} for {collection}\")\n\n        if page == 1:\n            paginated_collection_path = os.path.join(SITE_DIR, f\"{template}/index.html\")\n        else:\n            paginated_collection_path = os.path.join(\n                SITE_DIR, f\"{template}/{page}/index.html\"\n            )\n\n        make_any_nonexistent_directories(os.path.dirname(paginated_collection_path))\n\n        paginated_collection_layout = f\"{ROOT_DIR}/{LAYOUTS_BASE_DIR}/{template}.html\"\n\n        paginated_collection_contents = all_opened_pages[paginated_collection_layout]\n\n        paginated_collection_state = state.copy()\n        paginated_collection_state[collection[0][\"layout\"]] = paginated_collection\n        paginated_collection_state[\"current_page\"] = paginated_collection\n        paginated_collection_state[\"page_number\"] = page\n\n        page = deepcopy(all_parsed_pages[paginated_collection_layout])\n        page[collection[0][\"layout\"]] = paginated_collection\n\n        fm = interpolate_front_matter(page, paginated_collection_state)\n\n        rendered_page = paginated_collection_contents.render(\n            paginated_collection_state,\n            site=state,\n            posts=paginated_collection,\n            page=paginated_collection_state,\n        )\n\n        if not paginated_collection_state.get(\"page\"):\n            paginated_collection_state[\"page\"] = {}\n\n        paginated_collection_state[\"page\"][\"template\"] = paginated_collection_layout\n\n        rendered_page = recursively_build_page_template_with_front_matter(\n            paginated_collection_path,\n            fm,\n            paginated_collection_state,\n            loads(rendered_page).content,\n        )\n\n        with open(\n            paginated_collection_path,\n            \"wb\",\n            buffering=500,\n        ) as f:\n            f.write(rendered_page.encode())\n\n\ndef process_date_archives() -> None:\n    \"\"\"\n    Generate date archives for all posts.\n\n    For example, if there are posts on 2022-01-01 and 2022-01-02, generate:\n\n    - /2022/index.html\n    - /2022/01/index.html\n    - /2022/01/01/index.html\n    \"\"\"\n\n    posts = [\n        key\n        for key in all_opened_pages.keys()\n        if key.startswith(os.path.join(ROOT_DIR, \"posts\"))\n    ]\n\n    dates = set()\n    years = {}\n\n    for post in posts:\n        if not hasattr(all_parsed_pages[post], \"metadata\"):\n            continue\n\n        if not all_parsed_pages[post].metadata.get(\"date\"):\n            continue\n\n        date = all_parsed_pages[post].metadata[\"date\"]\n        dates.add(date)\n        if date.year not in years:\n            years[date.year] = {}\n        if date.month not in years[date.year]:\n            years[date.year][date.month] = {}\n        if date.day not in years[date.year][date.month]:\n            years[date.year][date.month][date.day] = []\n        years[date.year][date.month][date.day].append(post)\n\n    for year in years:\n        make_any_nonexistent_directories(os.path.join(SITE_DIR, str(year)))\n\n        for month in years[year]:\n            make_any_nonexistent_directories(\n                os.path.join(SITE_DIR, str(year), str(month))\n            )\n\n            for day in years[year][month]:\n                ymd_slug = f\"{year}/{str(month).zfill(2)}/{str(day).zfill(2)}\"\n\n                generate_date_page_given_year_month_date(\n                    ymd_slug,\n                    years[year][month][day],\n                    datetime.datetime(year, month, day),\n                    \"day\",\n                )\n\n            all_posts_in_month = [\n                post for day in years[year][month] for post in years[year][month][day]\n            ]\n\n            generate_date_page_given_year_month_date(\n                f\"{year}/{str(month).zfill(2)}\",\n                all_posts_in_month,\n                datetime.datetime(year, month, 1),\n                \"month\",\n            )\n\n        all_posts_in_year = [\n            post\n            for month in years[year]\n            for day in years[year][month]\n            for post in years[year][month][day]\n        ]\n\n        generate_date_page_given_year_month_date(\n            str(year), all_posts_in_year, datetime.datetime(year, 1, 1), \"year\"\n        )\n\n        print(f\"Generated date archives for {year}\")\n\n    state[\"years\"] = years\n\n\ndef process_archives(name: str, state_key_associated_with_name: str, path: str):\n    \"\"\"\n    Generate category archives for all posts.\n\n    For example, if you have a post with the `category` key set to `writing`, generate:\n\n    - /writing/index.html\n    \"\"\"\n    categories = set()\n\n    for post in state[\"posts\"]:\n        if not post.get(state_key_associated_with_name):\n            continue\n\n        for category in post[state_key_associated_with_name]:\n            categories.add(category)\n\n    for category in categories:\n        make_any_nonexistent_directories(\n            os.path.join(SITE_DIR, path, slugify(category))\n        )\n\n        archive_layout = f\"{ROOT_DIR}/{LAYOUTS_BASE_DIR}/{name}.html\"\n        archive_contents = all_opened_pages[archive_layout]\n\n        archive_state = state.copy()\n        archive_state[name] = category\n        page = deepcopy(all_parsed_pages[archive_layout])\n        page[name] = category\n        archive_state[\"posts\"] = [\n            post\n            for post in state[\"posts\"]\n            if category in post.get(state_key_associated_with_name, [])\n        ]\n\n        print(f\"Generating archive for {category}\")\n\n        page[\"category\"] = category\n\n        fm = interpolate_front_matter(page, archive_state, \"category\")\n\n        fm[\"url\"] = f\"{BASE_URL}/{path}/{slugify(category)}/\"\n\n        rendered_page = archive_contents.render(\n            archive_state,\n            site=state,\n            posts=archive_state[\"posts\"],\n            page=archive_state,\n        )\n\n        if not archive_state.get(\"page\"):\n            archive_state[\"page\"] = {}\n\n        archive_state[\"page\"][\"template\"] = archive_layout\n\n        rendered_page = recursively_build_page_template_with_front_matter(\n            archive_layout,\n            fm,\n            archive_state,\n            loads(rendered_page).content,\n        )\n\n        with open(\n            os.path.join(SITE_DIR, path, slugify(category), \"index.html\"),\n            \"wb\",\n            buffering=500,\n        ) as f:\n            f.write(rendered_page.encode())\n\n\ndef copy_asset_to_site(assets: list) -> None:\n    \"\"\"\n    Copy an asset from the `assets` directory to the `_site/assets` directory.\n    \"\"\"\n    assets = [asset.replace(\"./assets/\", \"\") for asset in assets]\n\n    for a in assets:\n        print(f\"Copying {a} to _site/assets/{a}\")\n        make_any_nonexistent_directories(os.path.join(SITE_DIR, \"assets\"))\n        asset = read_file(os.path.join(\"assets\", a), \"rb\")\n        with open(os.path.join(SITE_DIR, \"assets\", a), \"wb\") as f2:\n            f2.write(asset)\n\n\ndef get_state_from_last_build() -> dict:\n    \"\"\"\n    Get the state from the last build.\n    \"\"\"\n    try:\n        data = json.load(open(\"state.json\", \"r\"))\n    except Exception as e:\n        print(\"Error reading state.json. Running a full build.\")\n        return {}\n\n    return data\n\n\ndef calculate_dependencies_from_saved_state(all_dependencies: dict) -> list:\n    \"\"\"\n    Read the saved state and compute dependencies of files that have changed since the last build.\n    \"\"\"\n    deps = []\n\n    last_build = datetime.datetime.strptime(\n        get_state_from_last_build().get(\"last_build\"), \"%Y-%m-%dT%H:%M:%S.%f\"\n    )\n\n    for root, dirs, files in os.walk(ROOT_DIR):\n        # add if has changed since last build\n        for file in files:\n            path = os.path.join(root, file)\n            # must be of parsable extension\n            if os.path.splitext(file)[-1].replace(\".\", \"\") not in ALLOWED_EXTENSIONS:\n                continue\n\n            if os.path.getmtime(path) > last_build.timestamp():\n                print(\n                    f\"Detected change in {path}. Rebuilding this page and its dependencies.\"\n                )\n\n                dependencies_of_dependencies = [\n                    i for i in all_dependencies if path in all_dependencies[i]\n                ] + [path]\n                deps.extend(dependencies_of_dependencies)\n\n    return deps\n\n\ndef load_data_from_data_files(deps: list, data_file_integrity: dict) -> list:\n    \"\"\"\n    Read all data files and create YAML file that can be used to generate pages.\n    \"\"\"\n\n    changed_files = []\n\n    for data_file in all_data_files:\n        data_dir = data_file.replace(\".json\", \"\").replace(\".csv\", \"\")\n        collections_to_files[data_dir] = []\n        idx = 0\n        print(f\"Loading data from {data_file}...\")\n\n        for record in tqdm.tqdm(all_data_files[data_file]):\n            if not record.get(\"slug\"):\n                # print(\n                #     f\"Note: {data_file} {record} does not have a 'slug' key. Assigning substitute ID.\"\n                # )\n                record[\"slug\"] = str(idx)\n                idx += 1\n\n            if not record.get(\"layout\"):\n                record[\"layout\"] = data_dir\n\n            slug = record.get(\"slug\")\n            path = os.path.join(data_dir, \"index.html\")\n\n            record_as_string = orjson.dumps(record).decode()\n\n            if (\n                data_file_integrity.get(slug)\n                != hashlib.sha1(record_as_string.encode()).hexdigest()\n            ):\n                changed_files.append(path)\n                data_file_integrity[slug] = hashlib.sha1(\n                    record_as_string.encode()\n                ).hexdigest()\n\n            try:\n                contents = \"---\\n\" + record_as_string + \"\\n---\\n\"\n                loaded_contents = loads(contents)\n                loaded_contents[\"skip\"] = data_dir in SITE_STATE.get(\n                    \"disable_collection_single_page_generation\", {}\n                )\n                if \"body\" in loaded_contents:\n                    loaded_contents[\"content\"] = loaded_contents[\"body\"]\n                    del loaded_contents[\"body\"]\n                all_opened_pages[path] = contents\n                all_page_contents[path] = loaded_contents\n                all_parsed_pages[path] = loaded_contents\n                collections_to_files[data_dir].append(path)\n            except ReaderError as e:\n                print(\n                    f\"Error reading {data_file} {record}. This page will not be generated.\",\n                )\n                # delete from all_page_contents\n                all_page_contents.pop(path, None)\n                all_opened_pages.pop(path, None)\n                all_opened_pages.pop(path, None)\n                continue\n\n    return changed_files\n\n\ndef get_data_files_in_folder(folder: str) -> list:\n    folder = os.path.abspath(folder)  # Convert to absolute path once\n    files = []\n    for entry in os.listdir(folder):\n        path = os.path.join(folder, entry)\n        if os.path.isdir(path):\n            files.extend(get_data_files_in_folder(path))\n        elif os.path.isfile(path) and path.endswith('.json'):\n            files.append(path)\n    return files\n\n\ndef main(deps: list = [], watch: bool = False, incremental: bool = False) -> None:\n    \"\"\"\n    The Aurora runtime.\n\n    Aurora can be run in two ways:\n\n    - `aurora build` to build the site once, and;\n    - `aurora serve` to watch for changes in the `pages` directory and rebuild the site in real time.\n    \"\"\"\n\n    global state\n    global all_dependencies\n\n    data_file_integrity = {}\n\n    start = datetime.datetime.now()\n\n    if os.path.exists(DATA_FILES_DIR):\n        for file in get_data_files_in_folder(DATA_FILES_DIR):\n            # remove base /Users/james/src/airport-pianos/pages/\n            file = file.replace(os.path.abspath(DATA_FILES_DIR) + \"/\", \"\")\n            # if dir, recurse\n            file_contents = read_file(os.path.join(DATA_FILES_DIR, file))\n\n            if os.path.splitext(file)[-1].replace(\".\", \"\") == \"json\":\n                all_data_files[file] = orjson.loads(file_contents)\n                if isinstance(all_data_files[file], dict):\n                    all_data_files[file] = [\n                        {k: v for k, v in all_data_files[file].items()}\n                    ]\n                state[file.replace(\".json\", \"\")] = all_data_files[file]\n            elif os.path.splitext(file)[-1].replace(\".\", \"\") == \"csv\":\n                all_data_files[file] = list(csv.DictReader(file_contents.split(\"\\n\")))\n                state[file.replace(\".csv\", \"\")] = all_data_files[file]\n            else:\n                logging.debug(\n                    f\"Unsupported data file format: {file}\", level=logging.CRITICAL\n                )\n                print(f\"Unsupported data file format: {file}\")\n\n    if not os.path.exists(SITE_DIR):\n        os.makedirs(SITE_DIR)\n    else:\n        if not deps and not incremental:\n            for root, _, files in os.walk(SITE_DIR):\n                for file in files:\n                    os.remove(os.path.join(root, file))\n\n    for root, _, files in os.walk(ROOT_DIR):\n        for file in files:\n            ext = os.path.splitext(file)[-1].replace(\".\", \"\")\n            if ext not in ALLOWED_EXTENSIONS:\n                continue\n\n            all_pages.append(os.path.join(root, file))\n\n    for page in all_pages:\n        if deps and page not in deps and not incremental:\n            continue\n\n        contents = read_file(page)\n\n        try:\n            if page.endswith(\".md\"):\n                all_opened_pages[page] = contents\n            else:\n                all_opened_pages[page] = JINJA2_ENV.from_string(contents)\n\n            all_page_contents[page] = loads(contents)\n\n            if SITE_STATE.get(\"enable_backlinks\"):\n                page_links = BeautifulSoup(\n                    pyromark.html(contents), \"html.parser\"\n                ).find_all(\"a\", href=True)\n\n                # if in posts/, assign permalink\n                if page.startswith(\"pages/posts/\"):\n                    # permalink should be YYYY-MM-DD-slug.md turned into /YYYY/MM/DD/slug/\n                    yyyy_mm_dd = re.search(r\"\\d{4}-\\d{2}-\\d{2}\", page)\n                    if yyyy_mm_dd:\n                        yyyy_mm_dd = yyyy_mm_dd.group(0)\n                        slug = page.split(yyyy_mm_dd)[1].replace(\".md\", \"\")[1:]\n                        yyyy_mm_dd_slug = f\"{yyyy_mm_dd.replace('-', '/')}/{slug}\"\n\n                    # all_page_contents[page].metadata[\n                    #     \"permalink\"\n                    # ] = f\"/{yyyy_mm_dd_slug.strip('/')}/\"\n\n                all_page_contents[page].metadata[\"outgoing_links\"] = page_links\n        except Exception as e:\n            # logging.debug(f\"Error reading {page}\", level=logging.CRITICAL)\n            # pass\n            raise e\n\n    if SITE_STATE.get(\"enable_backlinks\"):\n        for page in all_opened_pages:\n            for link in all_page_contents[page].metadata.get(\"outgoing_links\", []):\n                state[\"backlinks\"][link[\"href\"]].append(\n                    {\n                        \"url\": all_page_contents[page].metadata.get(\"permalink\"),\n                        \"title\": all_page_contents[page].metadata.get(\"title\", \"\"),\n                    }\n                )\n\n    # sort all_opened_pages alpha\n    all_opened_pages_sorted = list(sorted(all_page_contents.items()))\n    # reverse so that we can get next and previous\n    all_opened_pages_sorted.reverse()\n\n    for i, page in enumerate(all_opened_pages_sorted):\n        # add next and previous page\n        if i < len(all_opened_pages_sorted) - 1:\n            all_page_contents[page[0]].metadata[\"previous\"] = {\n                \"url\": all_opened_pages_sorted[i + 1][1].metadata.get(\"permalink\", \"\"),\n                \"title\": all_opened_pages_sorted[i + 1][1].metadata.get(\"title\", \"\"),\n            }\n\n            previous_in_same_category = None\n            # look at all posts before i\n            for j in range(i + 1, len(all_opened_pages_sorted)):\n                # print(f\"Comparing {all_opened_pages_sorted[j][1].metadata.get('categories', [])} with {page[1].metadata.get('categories', [])}\")\n                if all_opened_pages_sorted[j][1].metadata.get(\"categories\", []) == page[\n                    1\n                ].metadata.get(\"categories\"):\n                    previous_in_same_category = all_opened_pages_sorted[j][1]\n                    break\n\n            if previous_in_same_category:\n                # 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}\")\n                all_page_contents[page[0]].metadata[\"previous_in_same_category\"] = {\n                    \"url\": previous_in_same_category.metadata.get(\"permalink\", \"\"),\n                    \"title\": previous_in_same_category.metadata.get(\"title\", \"\"),\n                }\n\n        if i > 0:\n            all_page_contents[page[0]].metadata[\"next\"] = {\n                \"url\": all_opened_pages_sorted[i - 1][1].metadata.get(\"permalink\", \"\"),\n                \"title\": all_opened_pages_sorted[i - 1][1].metadata.get(\"title\", \"\"),\n            }\n\n            next_in_same_category = None\n\n            for j in range(i - 1, -1, -1):\n                if all_opened_pages_sorted[j][1].metadata.get(\"categories\", []) == page[\n                    1\n                ].metadata.get(\"categories\"):\n                    next_in_same_category = all_opened_pages_sorted[j][1]\n                    break\n\n            if next_in_same_category:\n                all_page_contents[page[0]].metadata[\"next_in_same_category\"] = {\n                    \"url\": next_in_same_category.metadata.get(\"permalink\", \"\"),\n                    \"title\": next_in_same_category.metadata.get(\"title\", \"\"),\n                }\n\n    if deps:\n        deps = set(deps)\n        new_deps = []\n\n        while deps:\n            dep = deps.pop()\n            new_deps.append(dep)\n            if dep in reverse_deps:\n                deps.update(reverse_deps[dep])\n\n        deps = new_deps\n\n    if incremental:\n        data = get_state_from_last_build()\n\n        if data != {}:\n            data_file_integrity = data.get(\"data_file_integrity\", {})\n            changed_files = load_data_from_data_files(deps, data_file_integrity)\n            deps.extend(changed_files)\n            deps.extend(calculate_dependencies_from_saved_state(all_dependencies))\n\n            if len(deps) == 0:\n                print(\"No changes detected. Exiting.\")\n                return\n        else:\n            load_data_from_data_files(deps, data_file_integrity)\n    else:\n        load_data_from_data_files(deps, data_file_integrity)\n\n    for page, contents in all_opened_pages.items():\n        # if incremental, only recompute dependencies for changed files\n        if deps and page not in deps and not incremental:\n            continue\n\n        dependencies, parsed_page = get_file_dependencies_and_evaluated_contents(\n            page, contents\n        )\n        all_dependencies[page] = dependencies\n        all_parsed_pages[page] = parsed_page\n\n        for dependency in dependencies:\n            if dependency not in reverse_deps:\n                reverse_deps[dependency] = set()\n            reverse_deps[dependency].add(page)\n\n        if page.startswith(\"posts/\"):\n            state[\"posts\"].append(parsed_page)\n\n    posts = [\n        key\n        for key in all_opened_pages.keys()\n        if key.startswith(os.path.join(ROOT_DIR, \"/posts\"))\n    ]\n\n    for post in posts:\n        if not hasattr(all_parsed_pages[post], \"metadata\"):\n            continue\n\n        if all_parsed_pages[post].metadata.get(\"date\"):\n            date = all_parsed_pages[post].metadata[\"date\"]\n            dates.add(date)\n            if date.year not in years:\n                years[date.year] = {}\n            if date.month not in years[date.year]:\n                years[date.year][date.month] = {}\n            if date.day not in years[date.year][date.month]:\n                years[date.year][date.month][date.day] = []\n            years[date.year][date.month][date.day].append(post)\n\n    state[\"years\"] = years\n\n    state[\"posts\"] = sorted(\n        state[\"posts\"],\n        # if post has \"hms\", sort by slug date then hms; otherwise, sort by slug\n        key=lambda x: (\n            \"-\".join(x.metadata[\"slug\"].split(\"-\")[:3]) + \"-\" + x.metadata.get(\"hms\", \"\")\n            if x.metadata.get(\"hms\")\n            else x[\"slug\"]\n        ),\n        reverse=True,\n    )\n\n    all_dependencies = {\n        k: v for k, v in all_dependencies.items() if not k.startswith(\"pages/_\")\n    }\n\n    dependencies = (\n        deps\n        if incremental and len(deps) > 0\n        else list(toposort_flatten(all_dependencies))\n    )\n\n    dependencies = [\n        dependency\n        for dependency in dependencies\n        if not dependency.startswith(\"pages/_\")\n    ]\n\n    if watch:\n        iterator = dependencies\n    else:\n        iterator = tqdm.tqdm(dependencies)\n\n    iterator_set = set(iterator)\n\n    print(\"Generating pages in memory...\")\n\n    for file in iterator:\n        if os.path.isdir(file):\n            for root, _, files in os.walk(file):\n                for file in files:\n                    file_path = os.path.join(root, file)\n                    if file_path not in iterator_set:\n                        render_page(file_path, skip_hooks=watch)\n        else:\n            render_page(file, skip_hooks=watch)\n\n    print(\"Saving files to disk...\")\n\n    if not incremental:\n        for root, _, files in os.walk(\"assets\"):\n            for file in files:\n                path = os.path.join(SITE_DIR, root)\n                if not os.path.exists(path):\n                    os.makedirs(path)\n                contents = read_file(os.path.join(root, file), \"rb\")\n\n                with open(os.path.join(SITE_DIR, root, file), \"wb\") as f2:\n                    f2.write(contents)\n\n    if incremental and deps:\n        for file in tqdm.tqdm(state_to_write):\n            if original_file_to_permalink.get(file) in deps:\n                with open(file, \"wb\", buffering=1000) as f:\n                    f.write(state_to_write[file].encode())\n    else:\n        for file in tqdm.tqdm(state_to_write):\n            with open(file, \"wb\", buffering=1000) as f:\n                f.write(state_to_write[file].encode())\n\n    if any(k.startswith(\"pages/\") for k in all_dependencies):\n        if \"skip_date_archive_page_generation\" not in SITE_STATE:\n            process_date_archives()\n        if \"skip_category_page_generation\" not in SITE_STATE:\n            process_archives(\n                SITE_STATE.get(\"category_template\", \"category\"),\n                \"categories\",\n                SITE_STATE.get(\"category_slug_root\", \"category\"),\n            )\n        if \"skip_tag_page_generation\" not in SITE_STATE:\n            process_archives(\n                SITE_STATE.get(\"tag_template\", \"tag\"),\n                \"tags\",\n                SITE_STATE.get(\"tag_slug_root\", \"tag\"),\n            )\n\n    for collection_name, attributes in SITE_STATE.get(\"paginators\", {}).items():\n        generate_paginated_page_for_collection(\n            collection_name, attributes[\"per_page\"], attributes[\"template\"]\n        )\n\n    for hooks in EVALUATED_POST_BUILD_HOOKS.values():\n        for hook in hooks:\n            hook(state)\n\n    if incremental:\n        to_save = {\n            \"last_build\": state[\"build_timestamp\"],\n            \"data_file_integrity\": data_file_integrity,\n        }\n\n        json.dump(to_save, open(\"state.json\", \"w\"))\n\n    print(\n        f\"Built site in \\033[94m{(datetime.datetime.now() - start).total_seconds():.3f}s\\033[0m ✨\\n\"\n    )\n\n    if watch:\n        from livereload import Server\n\n        srv = Server()\n\n        for permalink, files in permalinks.items():\n            if len(files) > 1:\n                yellow = \"\\033[93m\"\n                print(\n                    f\"{yellow}Warning: {permalink} has multiple files: {files}{yellow}\"\n                )\n\n        # logging.disable(logging.INFO)\n\n        print(\"Live reload mode enabled.\\nWatching for changes...\\n\")\n        print(\"View your site at \\033[92mhttp://localhost:8000\\033[0m\")\n        print(\"Press Ctrl+C to stop.\")\n\n        srv.watch(ROOT_DIR, lambda: main(deps=[srv.watcher.filepath], incremental=True))\n        srv.watch(\"./assets\", lambda: copy_asset_to_site([srv.watcher.filepath]))\n        srv.serve(root=SITE_DIR, liveport=35729, port=8000, debug=False)\n    else:\n        for permalink, files in permalinks.items():\n            if len(files) > 1:\n                yellow = \"\\033[93m\"\n                print(\n                    f\"{yellow}Warning: {permalink} has multiple files: {files}{yellow}\"\n                )\n"
  },
  {
    "path": "aurora/templates/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n        <title>Welcome to your website!</title>\n        <style>\n            body {\n                font-family: Arial, sans-serif;\n                margin: 2em;\n            }\n            h1 {\n                color: #333;\n            }\n            ul {\n                list-style-type: none;\n                padding: 0;\n                display: grid;\n                grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));\n            }\n            li {\n                margin-bottom: 1em;\n                background-color: #f7f7f7;\n                border-radius: 1em;\n                padding: 1em;\n            }\n            a {\n                color: blueviolet;\n                text-decoration: none;\n            }\n            a:hover {\n                text-decoration: underline;\n            }\n        </style>\n    </head>\n    <body>\n        <h1>Welcome to your new website! 🤗</h1>\n        <p>Below are some helpful links to get you started in making your site with Aurora.</p>\n        <ul>\n            <li>\n                <h2><a href=\"https://github.com/capjamesg/aurora\">Read the Aurora documentation.</a></h2>\n            </li>\n        </ul>\n    </body>\n</html>\n"
  },
  {
    "path": "docs/assets/prism.css",
    "content": "/* PrismJS 1.29.0\nhttps://prismjs.com/download.html#themes=prism&languages=markup+bash+python */\ncode[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}\n"
  },
  {
    "path": "docs/assets/prism.js",
    "content": "/* PrismJS 1.29.0\nhttps://prismjs.com/download.html#themes=prism&languages=markup+bash+python */\nvar _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);\nPrism.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;\n!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);\nPrism.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;\n"
  },
  {
    "path": "docs/config.py",
    "content": "import os\n\nBASE_URLS = {\n    \"local\": \"http://localhost:8000/\",\n    \"production\": \"https://jamesg.blog/aurora/\",\n}\n\nSITE_ENV = os.environ.get(\"SITE_ENV\", \"local\")\nBASE_URL = BASE_URLS[SITE_ENV]\nROOT_DIR = \"pages\"\nLAYOUTS_BASE_DIR = \"_layouts\"\nSITE_DIR = \"_site\"\nHOOKS = {\n    \"post_template_generation\": {\"highlighting\": [\"highlight_code\"]},\n    \"pre_template_generation\": {\"highlighting\": [\"generate_table_of_contents\"]},\n}\nSITE_STATE = {}\n"
  },
  {
    "path": "docs/highlighting.py",
    "content": "from bs4 import BeautifulSoup\nfrom pygments import highlight\nfrom pygments.formatters import HtmlFormatter\nfrom pygments.lexers import CssLexer, HtmlLexer, PythonLexer\n\nlanguages = {\n    \"python\": PythonLexer(),\n    \"html\": HtmlLexer(),\n    \"text\": HtmlLexer(),\n    \"css\": CssLexer(),\n}\n\n\ndef highlight_code(file_name, page_state, _, page_contents):\n    if \".txt\" in file_name or \".xml\" in file_name or \"styles.html\" in file_name:\n        return page_contents\n\n    soup = BeautifulSoup(page_contents, \"lxml\")\n\n    for pre in soup.find_all(\"pre\"):\n        code = pre.find(\"code\")\n        try:\n            language = code[\"class\"][0].split(\"language-\")[1]\n            code = highlight(code.text, languages[language], HtmlFormatter())\n        except:\n            continue\n\n        pre.replace_with(BeautifulSoup(code, \"html.parser\"))\n\n    if soup.find(\"article\", {\"class\": \"post\"}):\n        # add id to all h2s, h3s, etc.\n        for h2 in soup.find_all([\"h2\", \"h3\", \"h4\", \"h5\", \"h6\"]):\n            h2[\"id\"] = h2.text.lower().replace(\" \", \"-\")\n\n        # surround h2 with a link\n        for h2 in soup.find_all([\"h2\", \"h3\", \"h4\", \"h5\", \"h6\"]):\n            link = soup.new_tag(\"a\", href=f\"#{h2['id']}\")\n            link.string = h2.text  # Set the link text to the h2's current text\n            h2.clear()\n            h2.append(link)\n\n    # for each \"sup\", add class=f-1\n    for i, sup in enumerate(soup.find_all(\"sup\")):\n        sup[\"id\"] = f\"f-{i+1}\"\n\n    # get all footnote-definition and add [↩] link to end\n    for footnote in soup.find_all(\"div\", {\"class\": \"footnote-definition\"}):\n        link = soup.new_tag(\"a\", href=f\"#f-{footnote['id']}\")\n        link.string = \"[↩]\"\n        footnote.append(link)\n\n    css = HtmlFormatter().get_style_defs(\".highlight\")\n    css = f\"<style>{css}</style>\"\n\n    # this happens for bookmarks\n    if not soup.find(\"body\"):\n        return \"\"\n\n    body = soup.find(\"body\")\n    body.insert(0, BeautifulSoup(css, \"html.parser\"))\n\n    return str(soup)\n\n\ndef generate_table_of_contents(file_name, page_state, site_state):\n    page = BeautifulSoup(page_state[\"page\"].contents, \"html.parser\")\n    h2s = page.find_all(\"h2\")\n    toc = []\n    for h2 in h2s:\n        toc.append(\n            {\"text\": h2.text, \"id\": h2.text.lower().replace(\" \", \"-\"), \"children\": []}\n        )\n        h3s = h2.find_next_siblings(\"h3\")\n        for h3 in h3s:\n            # if h3 is a child of another h3, skip it\n            if h3.find_previous_sibling(\"h2\") != h2:\n                continue\n            toc[-1][\"children\"].append(\n                {\n                    \"text\": h3.text,\n                    \"id\": h3.text.lower().replace(\" \", \"-\"),\n                }\n            )\n    page_state[\"page\"].toc = toc\n\n    return page_state\n"
  },
  {
    "path": "docs/pages/_layouts/default.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>{{ page.title }} | Aurora User Manual</title>\n\n    <meta name=\"description\" content=\"Aurora: An extensible, Python-based static site generator.\" />\n    <meta name=\"author\" content=\"Aurora\" />\n    <meta name=\"keywords\" content=\"Aurora, static site generator, Python\" />\n\n    <meta property=\"og:image\" content=\"https://screenshots.jamesg.blog?url={{ page.url }}\" />\n    <meta property=\"og:title\" content=\"{{ page.title }} - Aurora Documentation\" />\n    <meta property=\"og:description\" content=\"Aurora: An extensible, Python-based static site generator.\" />\n    <meta property=\"og:url\" content=\"{{ page.url }}\" />\n\n    <link rel=\"icon\" href=\"{{ site.root_url }}/assets/aurora-logo.png\" type=\"image/x-icon\" />\n\n    <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n    <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin />\n    <link\n        href=\"https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap\"\n        rel=\"stylesheet\"\n    />\n    <link href=\"{{ site.root_url }}/assets/prism.css\" rel=\"stylesheet\" />\n    <script src=\"{{ site.root_url }}/assets/prism.js\"></script>\n\n    <link href=\"https://jamesg.blog/assets/mascot.svg\" rel=\"icon\">\n\n    <style>\n        :root {  \n          --light-background-color: #f7f7f7;\n          --light-foreground-color: black;\n          --light-border-color: lightgrey;\n          --primary-color: #aaaaff;\n          --border-radius: var(--medium-space);\n          --dark-background-color: #242424;\n          --dark-code-color: #333345;\n          --dark-foreground-color: #d7d7d7;\n          --light-focus-color: rgb(255, 225, 116);\n          --dark-focus-color: #1e3cb1;\n          --dark-border-color: #747474;\n          --small-space: 0.1rem;\n          --medium-space: 0.5rem;\n          --large-space: 1rem;\n        }\n        html {\n            color-scheme: light dark;\n        }\n        .search {\n            list-style-type: none;\n            padding-left: 0;\n        }\n        .search li {\n            padding-left: 1em;\n            padding-right: 1em;\n            padding-top: 0.5em;\n            padding-bottom: 0.5em;\n            background-color: light-dark(white, var(--dark-background-color));;\n            box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.1);\n        }\n        .search li:hover {\n            background-color: rgba(0, 0, 0, 0.05);\n        }\n        .callout {\n            background-color: rgba(65, 105, 225, 0.198);\n            border-left: 3px solid #aaaaff;\n            padding: 1em;\n        }\n        .warning {\n            background-color: rgba(255, 165, 0, 0.198);\n            border-left: 3px solid orange;\n            padding: 1em;\n        }\n        @font-face {\n            font-family: \"Standard\";\n            src: url(\"standard-book-webfont.woff2\");\n            font-display: swap;\n        }\n        html {\n            background-color: #f9f9f9;\n            font-family: \"Standard\", sans-serif;\n            padding: 0;\n            margin: 0;\n            box-sizing: border-box;\n            border-top: 5px solid #aaaaff;\n            position: fixed;\n            width: 100%;\n        }\n        h1, h2 {\n            margin-bottom: 0.25em;\n            padding-bottom: 0;\n        }\n        h2 {\n            margin-top: 1.25em;\n        }\n        h1 + p, h2 + p {\n            margin-top: 0;\n            padding-top: 0;\n        }\n        body {\n            padding: 0;\n            margin: 0;\n        }\n        * {\n            line-height: 1.5;\n            color: light-dark(black, var(--dark-foreground-color));\n        }\n        #main {\n            display: grid;\n            grid-template-columns: 1fr 4fr;\n            background-color: light-dark(white, var(--dark-background-color));\n        }\n        article {\n            padding-bottom: 3em;\n        }\n        h3 {\n            text-transform: uppercase;\n            font-size: 0.9em;\n            margin-bottom: 0.25em;\n            margin-top: 1.5em;\n        }\n        aside {\n            border-right: 0.5px solid light-dark(rgb(57, 57, 57), var(--dark-border-color));\n            padding-right: 1.5em;\n        }\n        .right-sidebar {\n            position: sticky;\n            top: 1em;\n            padding-top: 1em;\n        }\n        .toc h3 {\n            margin: 0;\n        }\n        .pages ul {\n            list-style-type: none;\n            box-sizing: border-box;\n            max-width: 100%;\n            padding-left: 1em;\n        }\n        .pages li {\n            list-style-type: none;\n            padding: 0.5em 0;\n            /*! border-radius: 0.5em; */\n        }\n        .toc ul {\n            list-style-type: none;\n            padding-left: 1em;\n            margin-top: 0;\n        }\n        .toc a:hover {\n            color: rgb(26, 46, 109);\n        }\n        .pages a, pre {\n            display: block;\n            background-color: light-dark(white, var(--dark-background-color));;\n            box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.1);\n            padding-left: 1em;\n            padding-right: 1em;\n        }\n        pre {\n            padding: 1em;\n            text-wrap: stable;\n            border: 0.1em solid light-dark(rgb(57, 57, 57), var(--dark-border-color));\n        }\n        nav {\n            background-color: light-dark(white, var(--dark-background-color));;\n            padding: 0.5em;\n            /*! padding-left: 2.5em; */\n            border-bottom: 0.5px solid light-dark(rgb(57, 57, 57), var(--dark-border-color));\n        }\n        .logo-head {\n            display: flex;align-content: center;align-items: center;margin-left: 0.5em;\n        }\n        nav h1 {\n            font-size: 2em;\n            padding: 0;\n            margin: 0;\n        }\n        ul a {\n            color: black;\n            text-decoration: none;\n        }\n        article a {\n            color: #aaaaff;\n            text-decoration: none;\n        }\n        ul a:hover {\n            background-color: rgba(0, 0, 0, 0.05);\n        }\n        .focused {\n            border-left: 3px solid #aaaaff;\n            padding-left: 0.5em;\n            text-decoration: none;\n            font-weight: bold;\n        }\n        p code {\n            background-color: light-dark(#f1f1f1, var(--dark-code-color));\n        }\n        body {\n            height: 100vh;\n        }\n        aside, main {\n            height: calc(100vh - 4em);\n            overflow-y: auto;\n        }\n        body {\n            scrollbar-width: none;\n        }\n        h1 {\n            margin-top: 0;\n        }\n        main {\n            display: grid;\n            gap: 1em;\n            grid-template-columns: 8fr 2fr;\n        }\n        main aside {\n            position: sticky;\n            /*! top: 1em; */\n            padding-top: 1em;\n        }\n        main aside h2 {\n            font-size: 1.25em;\n        }\n        main aside h3 {\n            font-size: 1em;\n            text-transform: none;\n            font-weight: normal;\n        }\n        nav {\n            display: grid;\n            list-style-type: none;\n            /*! padding: 1em; */\n            /*! padding-left: 2.5em; */\n            /*! padding-right: 2em; */\n            /*! margin: 0; */\n            grid-template-columns: 1fr 4fr;\n        }\n        nav ul {\n            display: flex;\n            list-style-type: none;\n            padding: 0;\n            margin: 0;\n        }\n        nav ul li {\n            margin-right: 1em;\n        }\n        article li {\n            margin-bottom: 1em;\n        }\n        .toc {\n            border-left: 0.5px solid light-dark(rgb(57, 57, 57), var(--dark-border-color));\n            padding-left: 1em;\n        }\n        h1 {\n            font-size: 1.5em;\n        }\n        .subtitle {\n            font-size: 1em;\n        }\n        .pre-inner {\n            padding: 1em;\n        }\n        .code-head {\n            background-color: #f1f1f1;\n            padding: 0.5em;\n            padding-left: 1em;\n            border-bottom: 0.5px solid light-dark(rgb(57, 57, 57), var(--dark-border-color));\n        }\n        th {\n            text-align: left;\n        }\n        table {\n            border-collapse: collapse;\n            width: 100%;\n            border-spacing: 1em;\n            background: light-dark(white, var(--dark-background-color));;\n            box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.1);\n        }\n        td, th {\n            padding: 0.5em;\n        }\n        tr:first-child {\n            margin-right: 1em;\n        }\n        tr {\n            border-bottom: 0.5px solid light-dark(rgb(57, 57, 57), var(--dark-border-color));\n        }\n        input {\n            padding: 0.5em;\n            border: 0.5px solid light-dark(rgb(57, 57, 57), var(--dark-border-color));\n            width: 100%;\n            box-sizing: border-box;\n            background-color: rgba(0, 0, 0, 0.05);\n        }\n\n        .nav-title {\n            padding-left: 1.5em;\n        }\n        article {\n            padding-left: 2em;\n            padding-right: 2em;\n        }\n        .manual {\n            display: block;margin: 0;padding: 0;\n        }\n        nav a {\n            text-decoration: none;\n        }\n        .right-sidebar a {\n            text-decoration: none;\n            color: #aaaaff;\n        }\n        .right-sidebar svg {\n            display: inline;\n            height: 1em;\n            color: #aaaaff;\n        }\n        .menu {\n            background-color: light-dark(white, var(--dark-background-color));;\n            border-bottom: 0.5px solid light-dark(rgb(57, 57, 57), var(--dark-border-color));\n        }\n        .menu {\n            display: grid;\n            grid-template-columns: 1fr 1fr;\n        }\n        .jcb:hover {\n            background-color: #aaaaff;\n            a {\n                color: light-dark(white, var(--dark-background-color));;\n            }\n        }\n        .aurora:hover {\n            background-color: #aaaaff;\n        }\n        .menu ul {\n            display: flex;\n            justify-content: flex-end;\n            list-style-type: none;\n            padding: 0;\n            margin: 0;\n        }\n        .menu li {\n            padding-top: 0.25em;\n        }\n        .menu ul li {\n            padding-left: 1em;\n            padding-right: 1em;\n        }\n        .link-with-logo {\n            display: flex;\n            align-items: center;\n            margin-left: 1em;\n        }\n        .link-with-logo svg {\n            margin-right: 0.25em;\n        }\n        .nav-title svg {\n            display: none;\n        }\n        .toc a, .links a {\n            color: light-dark(rgb(81, 81, 81), var(--dark-foreground-color));\n        }\n        video {\n            max-width: 100%;\n        }\n        @media (max-width: 800px) {\n            #main {\n                grid-template-columns: 1fr;\n            }\n            aside {\n                display: none;\n            }\n            main {\n                grid-template-columns: 1fr;\n            }\n            article {\n                max-width: 100%;\n                padding: 1em;\n            }\n            .nav-title svg {\n                display: block;\n                margin-left: auto;\n                padding-right: 0.5em;\n                max-height: 1.5em;\n            }\n            aside li svg {\n                display: inline-block;\n                width: 1em;\n                height: 1em;\n                margin-right: 0.5em;\n                align-self: center;\n            }\n            nav {\n                flex-direction: column;\n                align-items: center;\n            }\n            nav ul {\n                display: block;\n            }\n            nav ul li {\n                margin-right: 0;\n                margin-bottom: 0.5em;\n            }\n            .logo-head {\n                display: block;\n            }\n            h1 {\n                font-size: 1.25em;\n                margin-left: 0;\n            }\n            .manual {\n                margin-left: 1em;\n            }\n            article {\n                padding-left: 1em;\n            }\n            nav {\n                display: block;\n                padding: 0;\n                background-color: light-dark(white, var(--dark-background-color));;\n            }\n            #title {\n                font-size: 1em;\n            }\n            .nav-title {\n                padding-left: 0;\n            }\n            nav img {\n                display: none;\n            }\n            .nav-title {\n                background-color: light-dark(white, var(--dark-background-color));;\n                box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.1);\n                padding: 0.5em;\n                padding-left: 1em;\n                display: flex;\n                align-items: center;\n                justify-content: space-between;\n            }\n            .nav-title:hover {\n                background-color: light-dark(#f7f7f7, #333345);\n                cursor: pointer;\n            }\n            html {\n                border-top-width: 5px;\n            }\n            .logo-head {\n                background-color: light-dark(rgb(238, 237, 237), #333345);\n                color: light-dark(white, var(--dark-background-color));;\n                display: flex;\n                align-items: center;\n                padding: 0.5em;\n                padding-left: 1em;\n                margin: 0;\n            }\n            .logo-head div {\n                display: flex;\n                align-items: center;\n            }\n            table {\n                overflow-x: auto;\n                overflow-y: scroll;\n            }\n            .menu {\n                display: none;\n            }\n        }\n        .toc h3 {\n            margin-bottom: 0.25em;\n        }\n        footer {\n            display: grid;\n            grid-template-columns: 1fr 1fr;\n            margin-top: 2em;\n            background-color: light-dark(white, var(--dark-background-color));;\n            box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.1);\n            padding-left: 1em;\n            padding-right: 1em;\n            gap: 1em;\n        }\n        footer div:first-child {\n            border-right: 0.5px solid light-dark(rgb(57, 57, 57), var(--dark-border-color));\n        }\n        footer div:last-child {\n            display: flex;\n            justify-content: flex-end;\n        }\n        .pages ul {\n            padding-bottom: 3em;\n        }\n        .links {\n            margin-top: 3em;\n            border-left: 0.5px solid light-dark(rgb(57, 57, 57), var(--dark-border-color));\n        }\n        form {\n            display: flex;\n            align-items: center;\n            padding-left: 0;\n            padding-right: 0;\n        }\n        form svg {\n            margin-left: 0.5em;\n            height: 1.5em;\n        }\n        form button {\n            display: flex;\n            align-items: center;\n            background-color: inherit;\n            border: none;\n            outline: none;\n            cursor: pointer;\n        }\n        .search h2 {\n            margin-top: 0.5em;\n        }\n        .pages a:has(.current) {\n            border-left: 3px solid #aaaaff;\n            text-decoration: none;\n            font-weight: bold;\n        }\n        img {\n            max-width: 100%;\n        }\n        #template-grid, #user-grid {\n            display: grid;\n            grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));\n            gap: 1em;\n            list-style: none;\n            padding: 0;\n            margin: 0;\n        }\n        #template-grid img, #user-grid img {\n            height: 100%;\n            object-fit: cover;\n            object-position: 0 0;\n        }\n    </style>\n    <script>\n        document.addEventListener(\"DOMContentLoaded\", function() {\n            var focused = document.querySelector(\".focused\");\n            if (focused) {\n                focused.scrollIntoView({block: \"center\", inline: \"nearest\"});\n            }\n        });\n    </script>\n</head>\n<script>\n    document.addEventListener(\"DOMContentLoaded\", function() {\n        document.querySelector(\".nav-title\").addEventListener(\"click\", function() {\n            if (!document.querySelector(\".pages\").style.display || document.querySelector(\".pages\").style.display === \"none\") {\n                document.querySelector(\".pages\").style.display = \"block\";\n            } else {\n                document.querySelector(\".pages\").style.display = \"none\";\n            }\n        });\n        // scroll sidebar to focused link, with 1em padding\n        var focused = document.querySelector(\".focused\");\n        if (focused) {\n            focused.scrollIntoView({block: \"center\", inline: \"nearest\"});\n        }\n    });\n</script>\n<body>\n    <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>\n    <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>\n    <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>\n    <div id=\"main\">\n        <aside class=\"pages\">\n            <ul>\n                <h3>Get Started</h3>\n                <a href=\"{{ site.root_url }}\"\n                    ><li {% if page.permalink.strip() == \"{{ site.root_url.strip() }}\" %}class='current'{% endif %}>What is Aurora?</li></a\n                >\n                <a href=\"{{ site.root_url }}start/\"><li {% if page.permalink == \"/start/\" %}class='current'{% endif %}>Start a Website</li></a>\n                <a href=\"{{ site.root_url }}blog/\"><li {% if page.permalink == \"/blog/\" %}class='current'{% endif %}>Use Aurora as a Blog</li></a>\n                <a href=\"{{ site.root_url }}structure/\"><li {% if page.permalink == \"/structure/\" %}class='current'{% endif %}>Site Structure</li></a>\n                <a href=\"{{ site.root_url }}configuration/\"><li {% if page.permalink == \"/configuration/\" %}class='current'{% endif %}>Configuration</li></a>\n                <a href=\"{{ site.root_url }}build-methods/\"><li {% if page.permalink == \"/build-methods/\" %}class='current'{% endif %}>Build Methods</li></a>\n                <a href=\"{{ site.root_url }}templating/\"><li {% if page.permalink == \"/templating/\" %}class='current'{% endif %}>Templating with Jinja2</li></a>\n                <a href=\"{{ site.root_url }}archives/\"><li {% if page.permalink == \"/archives/\" %}class='current'{% endif %}>Date, Category, and Tag Archives</li></a>\n                <h3>Build Sites with Data</h3>\n                <a href=\"{{ site.root_url }}collections/\"><li {% if page.permalink == \"/collections/\" %}class='current'{% endif %}>Collections</li></a>\n                <a href=\"{{ site.root_url }}pagination/\"><li {% if page.permalink == \"/pagination/\" %}class='current'{% endif %}>Pagination</li></a>\n                <h3>Advanced Features</h3>\n                <a href=\"{{ site.root_url }}state/\"><li {% if page.permalink == \"/state/\" %}class='current'{% endif %}>State</li></a>\n                <a href=\"{{ site.root_url }}hooks/\"><li {% if page.permalink == \"/hooks/\" %}class='current'{% endif %}>Hooks</li></a>\n                <a href=\"{{ site.root_url }}dates/\"><li {% if page.permalink == \"/dates/\" %}class='current'{% endif %}>Date Handling</li></a>\n                <a href=\"{{ site.root_url }}permalinks/\"><li {% if page.permalink == \"/permalinks/\" %}class='current'{% endif %}>Permalinks</li></a>\n                <a href=\"{{ site.root_url }}sitemap/\"><li {% if page.permalink == \"/sitemap/\" %}class='current'{% endif %}>Sitemap</li></a>\n                <a href=\"{{ site.root_url }}robots/\"><li {% if page.permalink == \"/robots/\" %}class='current'{% endif %}>robots.txt</li></a>\n                <h3>Demos</h3>\n                <a href=\"{{ site.root_url }}users/\"><li {% if page.permalink == \"/users/\" %}class='current'{% endif %}>Sites Built with Aurora</li></a>\n                <a href=\"{{ site.root_url }}templates/\"><li {% if page.permalink == \"/templates/\" %}class='current'{% endif %}>Aurora Templates</li></a>\n                <a href=\"{{ site.root_url }}performance/\"><li {% if page.permalink == \"/performance/\" %}class='current'{% endif %}>Performance</li></a>\n                <a href=\"{{ site.root_url }}design/\"><li {% if page.permalink == \"/design/\" %}class='current'{% endif %}>Aurora Design</li></a>\n            </ul>\n        </aside>\n        <main>\n            <article>\n                {{ content }}\n            </article>\n            {% if not site.page.notoc %}\n                <aside class=\"right-sidebar\">\n                    {% if site.page.toc %}\n                    <div class=\"toc\">\n                        <h2>Contents</h2>\n                        {% for item in site.page.toc %}\n                            <h3><a href=\"#{{ item.id }}\">{{ item.text }}</a></h3>\n                            {% if item.children %}\n                                <ul>\n                                    {% for child in item.children %}\n                                        <li><a href=\"#{{ child.id }}\">{{ child.text }}</a></li>\n                                    {% endfor %}\n                                </ul>\n                            {% endif %}\n                        {% endfor %}\n                    </div>\n                    {% endif %}\n                    <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>\n                </aside>\n            {% endif %}\n        </main>\n    </div>\n</body>\n</html>\n"
  },
  {
    "path": "docs/pages/templates/404.html",
    "content": "---\ntitle: 404\npermalink: /404.html\nlayout: default\n---\n\n<p>This page does not exist. <a href=\"/\">Go back to the homepage.</a></p>\n"
  },
  {
    "path": "docs/pages/templates/archives.md",
    "content": "---\ntitle: Date, Category, and Tag Archives\nlayout: default\npermalink: /archives/\n---\n\nAurora has support built-in for generating date, category, and tag archives. These are useful for blogs.\n\n## Date Archives\n\nAurora automatically generates date archives for blog posts. You do not need to configure any setting to use this feature.\n\nDate archives are generated as follows:\n\n- `https://example.com/2024/`: All posts published in 2024.\n- `https://example.com/2024/01/`: All posts published in January 2024.\n- `https://example.com/2024/01/01/`: All posts published on January 1, 2024.\n\n## Category and Tag Archives\n\nAurora automatically generates category and tag archives.\n\nThese archives are generated if you specify `category` and/or `tag` attributes in your blog post front matters.\n\nCategory archives are generated as follows:\n\n- `https://example.com/category/<name>`: All posts with the specified category.\n\nTag archives are generated as follows:\n\n- `https://example.com/tag/<name>`: All posts with the specified tag.\n\n### Customize Category and Tag Paths\n\nYou can change the default category and tag path roots.\n\nTo do so, update the `SITE_STATE` value in your config.py configuration to include:\n\n<pre><code class=\"language-python\">SITE_STATE = {\n    \"category_slug_root\": \"categories\",\n    \"tag_slug_root\": \"tags\",\n}</code></pre>\n\nThe above example would change the category and tag paths to:\n\n- `https://example.com/categories/<name>`: All posts with the specified category.\n- `https://example.com/tags/<name>`: All posts with the specified tag."
  },
  {
    "path": "docs/pages/templates/blog.html",
    "content": "---\ntitle: Use Aurora as a Blog\npermalink: /blog/\nlayout: default\n---\n\n<p>Aurora has out-of-the-box features designed for use with blogs.</p>\n\n<h2>Write a Blog Post</h2>\n\n<p>To use Aurora as a blog, create a markdown file with the following name structure in your <code>pages/posts</code> directory:</p>\n\n<pre><code class=\"language-text\">YYYY-MM-DD-title.md</code></pre>\n\n<p>For example, <code>2020-01-01-hello-world.md</code>.</p>\n\n<p>Within this file, you can specify front matter and, optionally, jinja2 templating.</p>\n\n<p>Here is an example:</p>\n\n<pre><code class=\"language-html\">---\ntitle: Hello, World!\nlayout: post\n---\n\nThis is our first blog post.\n</code></pre>\n\n<p>This will be rendered as a blog post using the \"pages/_layouts/post.html\" file.</p>\n\n<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>\n\n<h2>Categories and Tags</h2>\n\n<p>Aurora can automatically generate archive pages for categories and tags.</p>\n\n<p>Categories and tags are treated as two separate collections.</p>\n\n<p>To use this feature, you need to give at least one blog post a category or a tag, like so:</p>\n\n<pre><code class=\"language-html\">---\ntitle: Hello, World!\nlayout: post\ncategories:\n  - Announcement\n---\n\nThis is our first blog post.\n</code></pre>\n\n<p>Categories and tags are not case-sensitive.</p>\n\n<p>You then need to specify either a category or tag layout file, depending on which you want to support.</p>\n\n<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>\n\n<p>This file will have access to a <code>page.posts</code> variable that lists all posts in the category or tag.</p>\n\n<p>Here is an example of a category layout file:</p>\n\n{% raw %}\n<pre><code class=\"language-html\">\n---\nlayout: default\ntitle: &quot;Category Archive&quot;\n---\n\n&lt;ul&gt;\n    {% for post in page.posts %}\n        &lt;li&gt;\n            &lt;a href=&quot;{{ post.url }}&quot;&gt;{{ post.title }}&lt;/a&gt;\n        &lt;/li&gt;\n    {% endfor %}\n&lt;/ul&gt;\n</code></pre>\n{% endraw %}\n\n<p>In this example, a page at <code>/category/announcement/index.html</code> will be generated.</p>\n\n<h2>Date Archives</h2>\n\n<p>Aurora can automatically generate date pages for categories and tags.</p>\n\n<p>Date archives show all posts published on a specific day.</p>\n\n<p><em>There is not currently support for month-based or year-based archives.</em></p>\n\n<p>To use this feature, you need to have at least one blog post.</p>\n\n<p>You then need to specify a date archive layout.</p>\n\n<p>To do so, create a file called <code>date.html</code> in your <code>pages/_layouts</code> directory.</p>\n\n<p>This file will have access to a <code>page.posts</code> variable that lists all posts published on a specific day.</p>\n\n<p>Here is an example of a category layout file:</p>\n\n{% raw %}\n<pre><code class=\"language-html\">\n---\nlayout: default\ntitle: &quot;Date Archive&quot;\n---\n\n&lt;ul&gt;\n    {% for post in page.posts %}\n        &lt;li&gt;\n            &lt;a href=&quot;{{ post.url }}&quot;&gt;{{ post.title }}&lt;/a&gt;\n        &lt;/li&gt;\n    {% endfor %}\n&lt;/ul&gt;\n</code></pre>\n{% endraw %}\n\n<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>\n\n<p>Now you have a blog set up with Aurora! 🎉</p>\n"
  },
  {
    "path": "docs/pages/templates/build-methods.md",
    "content": "---\ntitle: Build Methods\npermalink: /build-methods/\nlayout: default\n---\n\nThere are three ways you can build your Aurora site:\n\n1. Full build\n2. Incremental build\n3. Interactive, incremental build\n\n## Full Build\n\nA full build generates your entire website.\n\nYour site is saved in `_site`, ready for serving.\n\nTo build your site, navigate to the root directory of your project (the folder with the `config.py` file in it), and run:\n\n<pre><code class=\"language-bash\">aurora build</code></pre>\n\nYour site will be saved in and ready to serve from the `_site` directory.\n\n## Incremental Build\n\nAn incremental build generates only the files that have changed since the last build. This is faster than a full build.\n\nIf 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.\n\nFor 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.\n\nIncremental builds are designed to speed up the build process, particularly for large sites with thousands or tens of thousands of pages.\n\nTo run an incremental build, navigate to the root directory of your project and run:\n\n<pre><code class=\"language-bash\">aurora build --incremental</code></pre>\n\n<p class=\"callout-tip\"><b>Tip</b>: Incremental builds support CSV and JSON data files.</p>\n\n## Interactive, Incremental Build\n\nAn 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.\n\nThis 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.\n\nTo run an interactive, incremental build, navigate to the root directory of your project and run:\n\n<pre><code class=\"language-bash\">aurora serve</code></pre>\n\nA server will start on `http://localhost:8000`. Open this URL in your browser to view your site.\n\n<p class=\"callout-note\"><b>Note</b>: The interactive server should not be used in production.</p>\n"
  },
  {
    "path": "docs/pages/templates/collections-from-data.html",
    "content": "---\ntitle: Create a Collection from Data\npermalink: /collections-from-data/\nlayout: default\n---\n\n<p>You can turn data from JSON and CSV files into web pages.</p>\n<p>\n    This is useful if you have a data set that you want to turn into a website.\n</p>\n<p>\n    For example, you could export a list of coffee shops you have visited from a\n    spreadsheet and turn the list into a static website.\n</p>\n<h2>Create a Collection</h2>\n<h3>JSON</h3>\n<p>\n    To create a collection from a JSON file, add a new file to your site&#39;s\n    <code>pages/_data</code> directory. This file should have a\n    <code>.json</code> extension.\n</p>\n<p>Within the file, create a list that contains JSON objects, like this:</p>\n<pre><code class=\"language-python\">[\n    {\n        \"slug\": \"rosslyn-coffee\",\n        \"layout\": \"coffee\",\n        \"title\": \"Rosslyn Coffee in London is terrific.\"\n    }\n]\n</code></pre>\n<p>\n    This file is called\n    <code>pages/_data/coffee.json</code>.\n</p>\n<p>\n    Every entry <b>must</b> have a <code>layout</code> key. This corresponds\n    with the name of the template that will be used to render the page. For\n    example, the <code>coffee</code> layout will be rendered using the\n    <code>pages/_layouts/coffee.html</code> template.\n</p>\n<p>\n    Every entry <b>must</b> also have a <code>slug</code> key. This corresponds\n    with the name of the page that will be generated. In the case above, one\n    file will be created in the <code>_site</code> output directory:\n    <code>_site/coffee/rosslyn-coffee/index.html</code>.\n</p>\n<h3>CSV</h3>\n<p>\n    To create a collection from a CSV file, add a new file to your site&#39;s\n    <code>pages/_data</code> directory. This file should have a\n    <code>.json</code> extension.\n</p>\n<p>Here is an example CSV file:</p>\n<pre><code class=\"language-python\">slug,layout,title\nrosslyn-coffee,coffee,Rosslyn Coffee in London is terrific.\n</code></pre>\n<p class=\"callout\">\n    Your CSV file must have a header row that contains the keys for each entry.\n</p>\n<p>\n    This file is called\n    <code>pages/_data/coffee.csv</code>.\n</p>\n<p>\n    Every entry <b>must</b> have a <code>layout</code> key. This corresponds\n    with the name of the template that will be used to render the page. For\n    example, the <code>coffee</code> layout will be rendered using the\n    <code>pages/_layouts/coffee.html</code> template.\n</p>\n<p>\n    Every entry <b>must</b> also have a <code>slug</code> key. This corresponds\n    with the name of the page that will be generated. In the case above, one\n    file will be created in the <code>_site</code> output directory:\n    <code>_site/coffee/rosslyn-coffee/index.html</code>.\n</p>\n"
  },
  {
    "path": "docs/pages/templates/collections.md",
    "content": "---\ntitle: Data Collections\npermalink: /collections/\nlayout: default\n---\n\nData collections are groups of data on a website.\n\nYou can use collections to create lists of content items (i.e. all of the bookmarks on your website).\n\nYou can create a data collection by:\n\n1. Loading data from a JSON file\n2. Loading data from a CSV file\n3. Specifying a `collections` value on any page on your website\n\n<h2>Create a Collection</h2>\n<h3>JSON</h3>\n<p>\n    To create a collection from a JSON file, add a new file to your site&#39;s\n    <code>pages/_data</code> directory. This file should have a\n    <code>.json</code> extension.\n</p>\n<p>Within the file, create a list that contains JSON objects, like this:</p>\n<pre><code class=\"language-python\">[\n    {\n        \"slug\": \"rosslyn-coffee\",\n        \"layout\": \"coffee\",\n        \"title\": \"Rosslyn Coffee in London is terrific.\"\n    }\n]\n</code></pre>\n<p>\n    This file is called\n    <code>pages/_data/coffee.json</code>.\n</p>\n<p>\n    Every entry <b>must</b> have a <code>layout</code> key. This corresponds\n    with the name of the template that will be used to render the page. For\n    example, the <code>coffee</code> layout will be rendered using the\n    <code>pages/_layouts/coffee.html</code> template.\n</p>\n<p>\n    Every entry <b>must</b> also have a <code>slug</code> key. This corresponds\n    with the name of the page that will be generated. In the case above, one\n    file will be created in the <code>_site</code> output directory:\n    <code>_site/coffee/rosslyn-coffee/index.html</code>.\n</p>\n<h3>CSV</h3>\n<p>\n    To create a collection from a CSV file, add a new file to your site&#39;s\n    <code>pages/_data</code> directory. This file should have a\n    <code>.csv</code> extension.\n</p>\n<p>Here is an example CSV file:</p>\n<pre><code class=\"language-python\">slug,layout,title\nrosslyn-coffee,coffee,Rosslyn Coffee in London is terrific.\n</code></pre>\n<p class=\"callout\">\n    Your CSV file must have a header row that contains the keys for each entry.\n</p>\n<p>\n    This file is called\n    <code>pages/_data/coffee.csv</code>.\n</p>\n<p>\n    Every entry <b>must</b> have a <code>layout</code> key. This corresponds\n    with the name of the template that will be used to render the page. For\n    example, the <code>coffee</code> layout will be rendered using the\n    <code>pages/_layouts/coffee.html</code> template.\n</p>\n<p>\n    Every entry <b>must</b> also have a <code>slug</code> key. This corresponds\n    with the name of the page that will be generated. In the case above, one\n    file will be created in the <code>_site</code> output directory:\n    <code>_site/coffee/rosslyn-coffee/index.html</code>.\n</p>\n\n# Specify a Collections Attribute\n\nIf you want to group multiple existing files together, you can specify a `collections` attribute on any page on your website.\n\nTo do so, use the following syntax:\n\n<pre><code class=\"language-python\">---\ntitle: My Page\ncollections: coffee\n---</code></pre>\n\nYou can then access the collection like so:\n\n<pre><code class=\"language-python\">{% raw %}{% for item in coffee %}{% endraw %}\n    {{ item.title }}\n{% raw %}{% endfor %}{% endraw %}</code></pre>\n"
  },
  {
    "path": "docs/pages/templates/configuration.html",
    "content": "---\ntitle: Configure Your Website\nlayout: default\n---\n\n<p>\n    You need a <code>config.py</code> file in the directory in which you will\n    build your Aurora site. This file is automatically generated when you run\n    <code>aurora new [site-name]</code>.\n</p>\n<p>\n    This configuration file defines a few values that Aurora will use when\n    processing your website.\n</p>\n<p>\n    Here is the default <code>config.py</code> file, with accompanying comments:\n</p>\n<pre><code class=\"language-python\">import os\n\nBASE_URLS = {\n    \"local\": os.getcwd(),\n}\n\nSITE_ENV = os.environ.get(\"SITE_ENV\", \"local\")\nBASE_URL = BASE_URLS[SITE_ENV]\nROOT_DIR = \"pages\" # where your site pages are\nLAYOUTS_BASE_DIR = \"_layouts\" # where your site layouts are stored\nSITE_DIR = \"_site\" # the directory in which your site will be saved\nHOOKS = {} # used to register hooks (see Hooks documentation for details)\nSITE_STATE = {}</code></pre>\n\n<h2>Base URLs</h2>\n<p>\n    The <code>BASE_URLS</code> dictionary is used to define the base URL for\n    your site. This is useful if you want to maintain multiple environments for\n    your site (e.g., local, staging, production).\n</p>\n<p>\n    Here is an example configuration of a site that has a local and staging\n    environment:\n</p>\n<pre><code class=\"language-python\"><span class=\"hljs-keyword\">BASE_URLS </span>= {\n    <span class=\"hljs-string\">\"production\"</span>: <span class=\"hljs-string\">\"https://jamesg.blog\"</span>,\n    <span class=\"hljs-string\">\"staging\"</span>: <span class=\"hljs-string\">\"https://staging.jamesg.blog\"</span>,\n    <span class=\"hljs-string\">\"local\"</span>: os.getcwd(),\n}\n</code></pre>\n\n\n<h2>See Also</h2>\n\n<ul>\n    <li><a href=\"/state/\">State</a></li>\n    <li><a href=\"/hooks/\">Hooks</a></li>\n</ul>\n"
  },
  {
    "path": "docs/pages/templates/dates.html",
    "content": "---\nlayout: default\ntitle: Date Handling\npermalink: /dates/\n---\n\n<p>Aurora has several default filters that you can use to handle dates.</p>\n\n<ul>\n    <li>long_date - Converts a date to a long format, e.g. \"January 01, 2020\"</li>\n    <li>date_to_xml_string - Converts a date to an XML string, e.g. \"2020-01-01T00:00:00\"</li>\n    <li>archive_date - Converts a date to a format suitable for archivess, e.g. \"2020/01\"</li>\n    <li>month_number_to_written_month - Converts a month number to a written month, e.g. \"01\" to \"January\"</li>\n</ul>\n\n<p>These filters can be used like:</p>\n\n<pre><code class=\"language-python\">{% raw %}{{ date | long_date }}\n{{ date | date_to_xml_string }}\n{{ date | archive_date }}\n{{ date | month_number_to_written_month }}{% endraw %}</code></pre>\n"
  },
  {
    "path": "docs/pages/templates/design.html",
    "content": "---\ntitle: Aurora Design\npermalink: /design/\nlayout: default\n---\n\n<p>I have written several blog posts that explore the design, inspiration, and development of Aurora.</p>\n\n<p>See a list of these posts below.</p>\n\n<ul>\n    <li><a href=\"https://jamesg.blog/2024/06/18/aurora/\">Announcing Aurora</a></li>\n    <li><a href=\"https://jamesg.blog/2024/06/16/aurora-isr/\">Implementing Incremental Static Regeneration in Aurora </a></li>\n    <li><a href=\"https://jamesg.blog/2024/05/27/designing-aurora/\">Designing Aurora, a new static site generator </a></li>\n</ul>\n"
  },
  {
    "path": "docs/pages/templates/hooks.html",
    "content": "---\ntitle: Hooks\nlayout: default\npermalink: /hooks/\n---\n\n<p>\n    You can define custom functions that are run before a file is processed by\n    Aurora. You can use this feature to save metadata about a page that can then\n    be consumed by a template.\n</p>\n<p>These functions are called &quot;hooks&quot;.</p>\n<p>There are three types of hooks, which run:</p>\n<ol>\n    <li>As a jinja2 filter you can access on all pages (<code>template_filters</code> hook)</li>\n    <li>Immediately before a page is generated (<code>pre_generation</code> hook)</li>\n    <li>After your site has built (<code>post_build</code> hook)\n</ol>\n\n\n<p>To define a hook, you need to:</p>\n<ol>\n    <li>Write a hook function with the right type signature, and;</li>\n    <li>\n        Add the hook function to the\n        <code>HOOKS</code> dictionary in your <code>config.py</code> file.\n    </li>\n</ol>\n\n<p>Below are instructions on how to define each type of hook.</p>\n\n<h2>Filter Hooks</h2>\n\n<p>Filter hooks are registered as a jinja2 filter.</p>\n\n<p>These hooks are useful for manipulating specific values in a template (i.e. formatting dates, changing text).</p>\n\n<p>The type signature of this hook is:</p>\n\n<pre><code class=\"language-python\">\ndef hook_name(text: str) -&gt; str:\n    return text.upper()\n</code></pre>\n\n<p>You can register this hook in the <code>template_filter</code> hook:</p>\n\n<pre><code class=\"language-python\">\nHOOKS = {\n    \"template_filter\": {\n        \"example\": [\"hook_name\"]\n    }\n}\n</code></pre>\n\n<p>This hook can then be used in any template on your website:</p>\n\n<pre><code class=\"language-html\">\n&lt;h1&gt; \"hello world\" | hook_name &lt;/h1&gt;\n</code></pre>\n\n<h2>Pre-Generation Hooks</h2>\n\n<p>Pre-generation hooks run immediately before a page is generated.</p>\n\n<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>\n\n<p>The type signature of this hook is:</p>\n\n<pre><code class=\"language-python\">\ndef hook_name(file_name: str, page_state: dict, site_state: dict) -&gt; dict:\n    return page_state\n</code></pre>\n\n<p>You can register this hook in the <code>template_filter</code> hook:</p>\n\n<pre><code class=\"language-python\">\nHOOKS = {\n    \"pre_generation\": {\n        \"example\": [\"hook_name\"]\n    }\n}\n</code></pre>\n\n<h2>Post-Build Hooks</h2>\n\n<p>Post-build hooks run after your site has been built.</p>\n\n<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>\n\n<p>The type signature of this hook is:</p>\n\n<pre><code class=\"language-python\">\ndef hook_name(site_state: str) -&gt; None:\n    pass\n</code></pre>\n\n<p>You can register this hook in the <code>template_filter</code> hook:</p>\n\n<pre><code class=\"language-python\">\nHOOKS = {\n    \"post_build\": {\n        \"example\": [\"hook_name\"]\n    }\n}\n</code></pre>\n"
  },
  {
    "path": "docs/pages/templates/index.html",
    "content": "---\ntitle: Aurora\nlayout: default\n---\n\n<p>Aurora is a static site generator implemented in Python.</p>\n<p>With Aurora, you can generate thousands of static web pages in seconds.</p>\n<p>Aurora supports:</p>\n<ul>\n    <li>Static generation, with support for jinja2 logic</li>\n    <li>Incremental Static Regeneration (ISR) with hot reloading</li>\n    <li>Markdown and HTML content</li>\n    <li>Generating pages from CSV and JSON files</li>\n</ul>\n<p>Aurora is open source, and licensed under an MIT license.</p>\n<p><a href=\"/aurora/start/\">Build your first website with Aurora</a>.</p>\n<p><a href=\"https://github.com/capjamesg/aurora\">View source code</a>.</p>\n<h2 id=\"demos\">Demo</h2>\n<video controls=\"\" autoplay=\"\" loop=\"\" muted=\"\">\n    <source\n        src=\"https://github.com/capjamesg/aurora/assets/37276661/39f62bd8-cf5f-4d15-a325-7d433b7ceeb0\"\n        type=\"video/mp4\"\n    />\n    Your browser does not support the video tag.\n</video>\n<p>\n    <a href=\"/aurora/start/\"><button>Get Started</button></a>\n</p>\n"
  },
  {
    "path": "docs/pages/templates/pagination.md",
    "content": "---\ntitle: Pagination\nlayout: default\npermalink: /pagination/\n---\n\nYou can generate pagination pages for collections.\n\nThis is ideal if you have a collection with many items that you want to split into multiple pages for ease of navigation.\n\n## Usage\n\nTo set up pagination, first [create a collection](/collections/).\n\nThen, create a new layout in your `_layouts` directory. This layout will be used to generate the pagination pages.\n\nThis 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.\n\nTo see the current page number, reference the `page.page_number` variable.\n\nFinally, add a `paginators` key to the `SITE_STATE` value in your `config.py` file:\n\n<pre><code class=\"language-python\">SITE_STATE = {\n    \"paginators\": {\n        \"books\": {\n            \"per_page\": 10,\n            \"template\": \"books\"\n        }\n    }\n}</code></pre>"
  },
  {
    "path": "docs/pages/templates/performance.html",
    "content": "---\ntitle: Performance\nlayout: default\n---\n\n<p>\n    In a test generating 292,884 files from a CSV file with a single layer of\n    inheritance in each template, Aurora built the website in 140.59 seconds\n    (2m:20s).\n</p>\n<p>\n    In a test on a website with 1,763 files and multiple layers of inheritance,\n    Aurora built the website in 3.149s. The files in this test were a\n    combination of blog posts, static pages, and programmatic archives for blog\n    posts (date pages, category pages).\n</p>\n<p>\n    In a test rendering 4,000 markdown files with a single layer of inheritance\n    in each template, Aurora built the website in between 0.9 and 1.2 seconds.\n</p>\n<p>\n    In a test comparing 11ty to Aurora in generating the\n    <a href=\"https://github.com/capjamesg/airport-pianos\">Airport Pianos</a>\n    website (~45 pages), 11ty took 1.36 seconds to start and generate the site,\n    whereas Aurora took 0.034 seconds.\n</p>\n"
  },
  {
    "path": "docs/pages/templates/permalinks.html",
    "content": "---\ntitle: Set a Permalink\nlayout: default\npermalink: /permalinks/\n---\n\n<p>You can define custom permalinks for a page in its front matter.</p>\n\n<p>To do so, specify the permalink key:</p>\n\n<pre><code class=\"language-yaml\">---\ntitle: Book List\npermalink: /books/\nlayout: default\n---</code></pre>\n\n<p>The page above will be generated with the path <code>/books/</code>.</p>\n"
  },
  {
    "path": "docs/pages/templates/robots.html",
    "content": "---\ntitle: Set a robots.txt File\nlayout: default\n---\n\n<p>robots.txt files let you tell search engines which pages they can and can't index.</p>\n\n<p>To define a robots.txt file, create a file called robots.txt in your <code>pages/templates/</code> directory.</p>\n\n<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>\n\n<pre><code class=\"language-text\">User-agent: *\nAllow: /\n\nSitemap: /sitemap.xml\n</code></pre>\n\n<h2>See Also</h2>\n\n<ul>\n    <li><a href=\"https://developers.google.com/search/docs/crawling-indexing/robots/create-robots-txt\">Google's robots.txt guide</a></li>\n</ul>\n"
  },
  {
    "path": "docs/pages/templates/sitemap.html",
    "content": "---\ntitle: Set a Sitemap\nlayout: default\n---\n\n<p>To define a sitemap, create a file called sitemap.xml in your <code>pages/templates/</code> directory and add the following code:</p>\n\n<pre><code class=\"language-xml\">{% raw %}&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;\n&lt;urlset xmlns=&quot;http://www.sitemaps.org/schemas/sitemap/0.9&quot;&gt;\n    {% for page in site.pages %}\n        {% if not page.noindex %}\n            &lt;url&gt;\n                &lt;loc&gt;\n                    {{ page.url }}\n                &lt;/loc&gt;\n                &lt;lastmod&gt;{{ site.build_time }}&lt;/lastmod&gt;\n            &lt;/url&gt;\n        {% endif %}\n    {% endfor %}\n&lt;/urlset&gt;{% endraw %}</code></pre>\n"
  },
  {
    "path": "docs/pages/templates/start.html",
    "content": "---\ntitle: Start a Website\npermalink: /start/\nlayout: default\n---\n\n<p>Learn how to create a website with Aurora.</p>\n\n<h3 id=\"install-aurora\">Install Aurora</h3>\n<p>First, install Aurora:</p>\n<pre><code class=\"language-bash\">pip3 <span class=\"hljs-keyword\">install</span> aurora-ssg\n</code></pre>\n<h3 id=\"create-a-site\">Create a Site</h3>\n<p>To create a new site, run the following command:</p>\n<pre><code class=\"language-bash\">aurora new my-site\ncd my-site\n</code></pre>\n<p>\n    This will create a folder called\n    <code>my-site</code> with everything you need to start your Aurora site.\n</p>\n<p>There are two ways to run Aurora:</p>\n<ul>\n    <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>\n    <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>\n</ul>\n\n<p>To see your site locally, run:</p>\n<pre><code class=\"language-bash\">aurora serve</code></pre>\n<p>\n    This will start a local server at\n    <code>http://localhost:8000</code> on which your site will run.\n</p>\n<img src=\"/assets/newsite.png\" alt=\"A web page\" />\n\n<p>When you are ready to publish your site, you can run:</p>\n<pre><code class=\"language-bash\">aurora build</code></pre>\n\n<p>This will build your site into a folder called <code>_site</code>, which you can then upload to a web server.</p>\n\n<p>You have started an Aurora website 🎉.</p>\n<p>\n    Next up:\n    <a href=\"/structure/\" rel=\"next\">Add a page to your website</a>.\n</p>\n"
  },
  {
    "path": "docs/pages/templates/state.html",
    "content": "---\ntitle: State\npermalink: /state/\nlayout: default\n---\n\n<p>There are three types of state in Aurora: page, post and site.</p>\n\n<h2>Page State</h2>\n\n<p>Page state stores values that are only available on that page.</p>\n\n<p>For example, consider the following template:</p>\n\n<pre><code class=\"language-html\">---\ntitle: Hello, World!\nlayout: default\n---\n\nWelcome to the website!\n</code></pre>\n\n<p>Any value in the front matter is stored in the page state. This state can be accessed using:</p>\n\n<pre><code class=\"language-html\">{% raw %}{{ page.title }}{% endraw %}</code></pre>\n\n<div class=\"callout-tip\">\n    <p><b>Tip</b></p>\n    <p>You can access the name of the template from which a page was generated with:</p>\n\n    <pre><code class=\"language-html\">{% raw %}{{ page.generated_from }}{% endraw %}</code></pre>\n\n    <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>\n</div>\n\n<h2>Post State</h2>\n\n<p>Post state stores information about a blog post.</p>\n\n<p>You can access post state on any template that is used by a post.</p>\n\n<p>For example, consider the following template called pages/_layouts/post.html for rendering a blog post:</p>\n\n<pre><code class=\"language-html\">---\nlayout: default\n---\n\n&lt;h1&gt;{% raw %}{{ post.title }}{% endraw %}&lt;/h1&gt;\n\n&lt;p&gt;{% raw %}{{ post.content }}{% endraw %}&lt;/p&gt;\n</code></pre>\n\n<p>Here, we access the title and content of the post using the post state.</p>\n\n<p>This template (pages/_layouts/post.html) inherits from the default layout, and could be used on any blog post with:</p>\n\n<pre><code class=\"language-markup\">---\nlayout: post\ntitle: Hello, World!\n---\n\n...\n</code></pre>\n\n<h2>Site State</h2>\n\n<p>Page state stores values that are global to the website.</p>\n\n<p>You can access site state on any page.</p>\n\n<p>By default, site state contains:</p>\n\n<ul>\n    <li>A list of your posts (<code>site.posts</code>)</li>\n    <li>The root URL of your site (<code>site.root_url</code>)</li>\n    <li>The build date of your site (<code>site.build_date</code>)</li>\n    <li>A list of all pages in your site (<code>site.pages</code>)</li>\n</ul>\n\n<p>For example, consider the following template:</p>\n\n<pre><code class=\"language-html\">---\ntitle: Blog Home\nlayout: default\n---\n\n{% raw %}\n&lt;ul&gt;\n    {% for post in site.posts[:5] %}\n        &lt;li&gt;\n            &lt;a href=&quot;{{ post.url }}&quot;&gt;{{ post.title }}&lt;/a&gt;\n        &lt;/li&gt;\n    {% endfor %}\n&lt;/ul&gt;\n{% endraw %}\n</code></pre>\n\n<p>Here, we iterate over the first five posts in the site state and display them on the page.</p>\n\n<p>The above code could be used on a home page to display the most recent posts.</p>\n\n<p>You can add custom values to your site state by adding to the SITE_STATE dictionary in your config.py file:</p>\n\n<pre><code class=\"language-python\">SITE_STATE = {\n    'site_version': os.getenv('SITE_VERSION', '1.0.0')\n}\n</code></pre>\n"
  },
  {
    "path": "docs/pages/templates/structure.html",
    "content": "---\ntitle: Website Structure\npermalink: /structure/\nlayout: default\n---\n\n<p>When you create a new Aurora site, some folders and files are created by default.</p>\n\n<p>Below is a list of those files.</p>\n\n<pre><code class=\"language-text\">\n├── _site # where your generated site is stored\n│   └── index.html\n├── assets # where to store your CSS, JS, and images\n├── config.py # your Aurora configuration\n└── pages # any page in this directory is generated\n    ├── _data # store JSON / CSV files to be used in generating pages\n    ├── _layouts # page layouts\n    ├── posts # blog posts\n    └── templates # store single pages to generate\n        └── index.html\n</code></pre>\n\n<h2>See Also</h2>\n\n<ul>\n    <li><a href=\"/configuration/\">Site configuration</a></li>\n</ul>\n"
  },
  {
    "path": "docs/pages/templates/templates.md",
    "content": "---\ntitle: Templates\npermalink: /templates/\nlayout: default\n---\n\nBelow are templates you can use to get started with Aurora.\n\n<ul id=\"template-grid\">\n    <li>\n        <img src=\"https://github.com/capjamesg/aurora-blog-template/raw/main/blog.png\" />\n        <a href=\"https://github.com/capjamesg/aurora-blog-template\">Blog</a>\n    </li>\n    <li>\n        <img src=\"https://github.com/capjamesg/aurora-docs-template/raw/main/screenshot.png\" />\n        <a href=\"https://github.com/capjamesg/aurora-docs-template\">Documentation</a>\n    </li>\n</li>\n"
  },
  {
    "path": "docs/pages/templates/templating.md",
    "content": "---\ntitle: Templating with Jinja2\nlayout: default\npermalink: /templating/\n---\n\nAurora supports using [jinja2](https://jinja.palletsprojects.com/en/3.1.x/) to create template logic.\n\njinja2 is a popular Python templating engine with support for variable interpolation, conditionals, loops, and more.\n\nYou can use jinja2 in any HTML or markdown document in your Aurora project.\n\nHere is an example of a Jinja2 template that defines a blog home page:\n\n```html\n---\ntitle: Blog\nlayout: default\npermalink: /blog/\n---\n\n<h1>Blog</h1>\n\n{% for post in site.posts %}\n    <h2>{{ post.title }}</h2>\n    <p>{{ post.content }}</p>\n{% endfor %}\n```\n"
  },
  {
    "path": "docs/pages/templates/users.html",
    "content": "---\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        <img src=\"https://screenshots.jamesg.blog/?url=https://jamesg.blog\" />\n        <a href=\"https://jamesg.blog\">James&#39; Coffee Blog</a>\n        (1,500+ pages)\n    </li>\n    <li>\n        <img\n            src=\"https://screenshots.jamesg.blog/?url=https://airportpianos.org\"\n        />\n        <a href=\"https://airportpianos.org\">Airport Pianos</a>\n        (~45 pages)\n    </li>\n    <li>\n        <img\n            src=\"https://screenshots.jamesg.blog/?url=https://trainstationpianos.org\"\n        />\n        <a href=\"https://trainstationpianos.org\">Train Station Pianos</a>\n        (~20 pages)\n    </li>\n    <li>\n        <img\n            src=\"https://screenshots.jamesg.blog/?url=https://aurora.jamesg.blog\"\n        />\n        <a href=\"https://aurora.jamesg.blog\">Aurora Documentation</a>\n        (~10 pages)\n    </li>\n</ul>\n\n<p>\n    If you would like your site to be featured here, please submit a pull request to the\n    <a href=\"https://github.com/capjamesg/aurora\">Aurora GitHub repository</a>.\n</p>\n"
  },
  {
    "path": "docs/state.json",
    "content": "{\"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\"}}"
  },
  {
    "path": "requirements.txt",
    "content": "jinja2\nlivereload\ntoposort\npyromark~=0.9.3\npython-frontmatter\nrequests\nprogress\nclick\norjson\ntqdm\nchardet\nbs4\n"
  },
  {
    "path": "setup.py",
    "content": "import re\n\nimport setuptools\nfrom setuptools import find_packages\n\nwith open(\"./aurora/__init__.py\", \"r\") as f:\n    content = f.read()\n    # from https://www.py4u.net/discuss/139845\n    version = re.search(r'__version__\\s*=\\s*[\\'\"]([^\\'\"]*)[\\'\"]', content).group(1)\n\nwith open(\"README.md\", \"r\", encoding=\"UTF-8\") as fh:\n    long_description = fh.read()\n\nsetuptools.setup(\n    name=\"aurora-ssg\",\n    version=version,\n    author=\"capjamesg\",\n    author_email=\"readers@jamesg.blog\",\n    description=\"A fast static site generator implemented in Python.\",\n    long_description=long_description,\n    long_description_content_type=\"text/markdown\",\n    url=\"https://github.com/capjamesg/aurora\",\n    install_requires=[\n        \"jinja2\",\n        \"livereload\",\n        \"toposort\",\n        \"pyromark>=0.6,<0.10\",\n        \"python-frontmatter\",\n        \"requests\",\n        \"progress\",\n        \"click\",\n        \"orjson\",\n        \"tqdm\",\n        \"python-dateutil\",\n        \"chardet\",\n        \"bs4\"\n    ],\n    include_package_data=True,\n    package_data={\"\": [\"templates/index.html\"]},\n    packages=find_packages(exclude=(\"tests\",)),\n    entry_points={\n        \"console_scripts\": [\n            \"aurora = aurora.cli:main\",\n        ],\n    },\n    extras_require={\n        \"dev\": [\n            \"flake8\",\n            \"black==25.11.0\",\n            \"isort\",\n            \"twine\",\n            \"pytest\",\n            \"wheel\",\n            \"mkdocs-material\",\n            \"mkdocs\",\n        ],\n    },\n    classifiers=[\n        \"Programming Language :: Python :: 3\",\n        \"License :: OSI Approved :: MIT License\",\n        \"Operating System :: OS Independent\",\n    ],\n    python_requires=\">=3.7\",\n)\n"
  },
  {
    "path": "tests/fixtures/about.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n        <title>About - Library</title>\n    </head>\n    <body>\n        <p>We serve 100 readers every month.</p>\n    </body>\n</html>\n"
  },
  {
    "path": "tests/fixtures/about_ISO-8859-1.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n        <title>About ISO Test - Library</title>\n    </head>\n    <body>\n        <p>A test of a file encoded with ISO-8859-1.</p>\n\n<p>We serve 100 readers every month.</p>\n    </body>\n</html>"
  },
  {
    "path": "tests/fixtures/about_UTF-16-BE.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n        <title>About UTF-16 BE Test - Library</title>\n    </head>\n    <body>\n        <p>A test of a file encoded with UTF-16 BE.</p>\n\n<p>We serve 100 readers every month.</p>\n    </body>\n</html>"
  },
  {
    "path": "tests/fixtures/about_Windows-1252.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n        <title>About Windows-1252 Test - Library</title>\n    </head>\n    <body>\n        <p>A test of a file encoded with Windows-1252\t.</p>\n\n<p>We serve 100 readers every month.</p>\n    </body>\n</html>"
  },
  {
    "path": "tests/fixtures/book.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n        <title>The Great Gatsby - Library</title>\n    </head>\n    <body>\n        <h1>The Great Gatsby</h1>\n<p>F. Scott Fitzgerald</p>\n    </body>\n</html>\n"
  },
  {
    "path": "tests/fixtures/book_list.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n        <title>Book List - Library</title>\n    </head>\n    <body>\n        <h2>Books</h2>\n\n<ul>\n\n        <li>\n            <a href=\"/books/the-great-gatsby\">The Great Gatsby - F. SCOTT FITZGERALD </a>\n        </li>\n\n</ul>\n    </body>\n</html>\n"
  },
  {
    "path": "tests/fixtures/category_archive.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n        <title>Category Archive - Library</title>\n    </head>\n    <body>\n        <ul>\n\n        <li>\n            <a href=\"https://example.com/2024/01/01/first-post/\">Hello, World!</a>\n        </li>\n\n</ul>\n    </body>\n</html>\n"
  },
  {
    "path": "tests/fixtures/collection_pagination.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n        <title>Rooms - Library</title>\n    </head>\n    <body>\n        <ul>\n    \n        <li>\n            Study Hall\n        </li>\n    \n        <li>\n            Quiet Corner\n        </li>\n    \n</ul>\n    </body>\n</html>"
  },
  {
    "path": "tests/fixtures/date_year.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n        <title>Date Archive - Library</title>\n    </head>\n    <body>\n        <h1>Posts from 2024</h1>\n\n  <p>Below are the posts we wrote in 2024.</p>\n\n\n\n\n\n\n<ul>\n    \n        <li>\n            <a href=\"https://example.com/2024/01/01/first-post/\">Hello, World!</a>\n        </li>\n    \n</ul>\n    </body>\n</html>"
  },
  {
    "path": "tests/fixtures/date_year_month.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n        <title>Date Archive - Library</title>\n    </head>\n    <body>\n        <h1>Posts from 2024/01</h1>\n\n  <p>Below are the posts we wrote in 2024/01.</p>\n\n\n\n\n<ul>\n    \n        <li>\n            <a href=\"https://example.com/2024/01/01/first-post/\">Hello, World!</a>\n        </li>\n    \n</ul>\n    </body>\n</html>"
  },
  {
    "path": "tests/fixtures/date_year_month_day.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n        <title>Date Archive - Library</title>\n    </head>\n    <body>\n        <h1>Posts from January 01, 2024</h1>\n\n  <p>Below are the posts we wrote on January 01, 2024.</p>\n\n\n<ul>\n    \n        <li>\n            <a href=\"https://example.com/2024/01/01/first-post/\">Hello, World!</a>\n        </li>\n    \n</ul>\n    </body>\n</html>"
  },
  {
    "path": "tests/fixtures/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n        <title>Library</title>\n    </head>\n    <body>\n        <h1>Welcome to the library!</h1>\n        <p><a href=\"/books/\">Browse the books.</a></p>\n    </body>\n</html>\n"
  },
  {
    "path": "tests/fixtures/new_site_config.py",
    "content": "import os\n\nBASE_URLS = {\n    \"local\": os.getcwd(),\n    \"production\": \"https://example.com\",\n}\n\nSITE_ENV = os.environ.get(\"SITE_ENV\", \"local\")\nBASE_URL = BASE_URLS[SITE_ENV]\nROOT_DIR = \"pages\"\nLAYOUTS_BASE_DIR = \"_layouts\"\nSITE_DIR = \"_site\"\nHOOKS = {}\nSITE_STATE = {}\n"
  },
  {
    "path": "tests/fixtures/post.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n        <title>Hello, World! - Library Blog</title>\n    </head>\n    <body>\n        <p>This is our first blog post.</p>\n\n    </body>\n</html>\n"
  },
  {
    "path": "tests/fixtures/review.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n        <title>Review by James - Library</title>\n    </head>\n    <body>\n        <h1>James</h1>\n\n<p>An excellent book.</p>\n\n<p> 5/5 stars</p>\n    </body>\n</html>\n"
  },
  {
    "path": "tests/fixtures/robots.txt",
    "content": "User-Agent: *\nAllow: /\n"
  },
  {
    "path": "tests/fixtures/styles.css",
    "content": "* {\n    font-family: San system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;\n}\n"
  },
  {
    "path": "tests/fixtures/tag_archive.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n        <title>Tag Archive - Library</title>\n    </head>\n    <body>\n        <ul>\n    \n        <li>\n            <a href=\"https://example.com/2024/01/01/first-post/\">Hello, World!</a>\n        </li>\n    \n</ul>\n    </body>\n</html>"
  },
  {
    "path": "tests/library/assets/meta/robots.txt",
    "content": "User-Agent: *\nAllow: /\n"
  },
  {
    "path": "tests/library/assets/styles.css",
    "content": "* {\n    font-family: San system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;\n}\n"
  },
  {
    "path": "tests/library/config.py",
    "content": "import os\n\nBASE_URLS = {\n    \"local\": os.getcwd(),\n    \"production\": \"https://example.com\",\n}\n\nSITE_ENV = os.environ.get(\"SITE_ENV\", \"local\")\nBASE_URL = BASE_URLS[SITE_ENV]\nROOT_DIR = \"pages\"\nLAYOUTS_BASE_DIR = \"_layouts\"\nSITE_DIR = \"_site\"\nHOOKS = {\n    \"pre_template_generation\": {\"hooks\": [\"retrieve_visitor_count\"]},\n    \"post_build\": {\"hooks\": [\"add_made_by_file\"]},\n    \"template_filters\": {\"hooks\": [\"capitalize\"]},\n}\nSITE_STATE = {\n    \"category_slug_root\": \"category\",\n    \"tag_slug_root\": \"tag\",\n    \"paginators\": {\"rooms\": {\"per_page\": 50, \"template\": \"rooms\"}},\n}\n"
  },
  {
    "path": "tests/library/hooks.py",
    "content": "def retrieve_visitor_count(file_name, page_state, _):\n    page_state[\"visitors\"] = 100\n\n    return page_state\n\n\ndef add_made_by_file(state):\n    with open(\"_site/made-by.txt\", \"w\") as f:\n        f.write(\"Made by the library team.\")\n\n    return state\n\n\ndef capitalize(text):\n    return text.upper()\n"
  },
  {
    "path": "tests/library/pages/_data/books.json",
    "content": "[\n  {\"title\": \"The Great Gatsby\", \"author\": \"F. Scott Fitzgerald\", \"layout\": \"book-template\", \"slug\": \"the-great-gatsby\"}\n]\n"
  },
  {
    "path": "tests/library/pages/_data/reviews.csv",
    "content": "name,review,star,layout\nJames,An excellent book., 5,reader-review"
  },
  {
    "path": "tests/library/pages/_layouts/book-template.html",
    "content": "---\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",
    "content": "---\nlayout: default\ntitle: \"Category Archive\"\n---\n\n<ul>\n    {% for post in page.posts %}\n        <li>\n            <a href=\"{{ post.url }}\">{{ post.title }}</a>\n        </li>\n    {% endfor %}\n</ul>\n"
  },
  {
    "path": "tests/library/pages/_layouts/date.html",
    "content": "---\nlayout: default\ntitle: \"Date Archive\"\n---\n{% if page.date_type == \"year\" %}\n  <h1>Posts from {{ page.date | year }}</h1>\n\n  <p>Below are the posts we wrote in {{ page.date | year }}.</p>\n{% endif %}\n\n{% if page.date_type == \"month\" %}\n  <h1>Posts from {{ page.date | archive_date }}</h1>\n\n  <p>Below are the posts we wrote in {{ page.date | archive_date }}.</p>\n{% endif %}\n\n{% if page.date_type == \"day\" %}\n  <h1>Posts from {{ page.date | long_date }}</h1>\n\n  <p>Below are the posts we wrote on {{ page.date | long_date }}.</p>\n{% endif %}\n\n<ul>\n    {% for post in page.posts %}\n        <li>\n            <a href=\"{{ post.url }}\">{{ post.title }}</a>\n        </li>\n    {% endfor %}\n</ul>\n"
  },
  {
    "path": "tests/library/pages/_layouts/default.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n        <title>{{ page.title }} - Library</title>\n    </head>\n    <body>\n        {{ content }}\n    </body>\n</html>\n"
  },
  {
    "path": "tests/library/pages/_layouts/post.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n        <title>{{ post.title }} - Library Blog</title>\n    </head>\n    <body>\n        {{ content }}\n    </body>\n</html>\n"
  },
  {
    "path": "tests/library/pages/_layouts/reader-review.html",
    "content": "---\nlayout: default\ntitle: \"Review by {{ page.name }}\"\n---\n\n<h1>{{ page.name }}</h1>\n\n<p>{{ page.review }}</p>\n\n<p>{{ page.star }}/5 stars</p>\n"
  },
  {
    "path": "tests/library/pages/_layouts/rooms.html",
    "content": "---\nlayout: default\ntitle: Rooms\n---\n\n<ul>\n    {% for room in page.current_page %}\n        <li>\n            {{ room.title }}\n        </li>\n    {% endfor %}\n</ul>\n"
  },
  {
    "path": "tests/library/pages/_layouts/tag.html",
    "content": "---\nlayout: default\ntitle: \"Tag Archive\"\n---\n\n<ul>\n    {% for post in page.posts %}\n        <li>\n            <a href=\"{{ post.url }}\">{{ post.title }}</a>\n        </li>\n    {% endfor %}\n</ul>\n"
  },
  {
    "path": "tests/library/pages/posts/2024-01-01-first-post.md",
    "content": "---\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",
    "content": "---\ntitle: Quiet Corner\nlayout: default\ncollection: rooms\n---\n\n<h1>Quiet Corner</h1>"
  },
  {
    "path": "tests/library/pages/rooms/study-hall.html",
    "content": "---\ntitle: Study Hall\nlayout: default\ncollection: rooms\n---\n\n<h1>Study Hall</h1>"
  },
  {
    "path": "tests/library/pages/templates/about.html",
    "content": "---\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",
    "content": "---\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.visitors }} readers every month.</p>\n"
  },
  {
    "path": "tests/library/pages/templates/about_Windows-1252.html",
    "content": "---\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 {{ page.visitors }} readers every month.</p>\n"
  },
  {
    "path": "tests/library/pages/templates/book_list.html",
    "content": "---\ntitle: Book List\npermalink: /book-list/\nlayout: default\n---\n\n<h2>Books</h2>\n\n<ul>\n    {% for book in site.books %}\n        <li>\n            <a href=\"/books/{{ book.slug }}\">{{ book.title }} - {{ book.author | capitalize }} </a>\n        </li>\n    {% endfor %}\n</ul>\n"
  },
  {
    "path": "tests/library/pages/templates/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n        <title>Library</title>\n    </head>\n    <body>\n        <h1>Welcome to the library!</h1>\n        <p><a href=\"/books/\">Browse the books.</a></p>\n    </body>\n</html>\n"
  },
  {
    "path": "tests/library/state.json",
    "content": "{\"last_build\": \"2024-08-08T10:55:05.167577\", \"data_file_integrity\": {\"0\": \"83cab347b81062bf833f9de2a5f117ee63b02f99\", \"the-great-gatsby\": \"99a8216ddb24aee98b88bf4305c53882aee58d4c\"}}"
  },
  {
    "path": "tests/state.py",
    "content": "import os\nimport shutil\n\nTEST_FOLDER = os.path.join(os.getcwd(), \"tests/library\")\nBASE_SITE_DIRECTORY = os.path.join(TEST_FOLDER, \"_site\")\nFIXTURES_DIRECTORY = os.path.join(os.getcwd(), \"tests/fixtures\")\n\nos.chdir(TEST_FOLDER)\n\nfixtures = {}\n\nfor file in os.listdir(FIXTURES_DIRECTORY):\n    with open(os.path.join(FIXTURES_DIRECTORY, file)) as f:\n        fixtures[file] = f.read()\n\nfrom aurora.graph import main as build_site\n\n\ndef test_build_site():\n    build_site()\n    assert os.path.exists(\"_site\")\n\n\ndef test_config_file_presence():\n    assert os.path.exists(\"config.py\")\n\n\ndef test_rendered_page_from_data_file():\n    with open(\n        os.path.join(BASE_SITE_DIRECTORY, \"books/the-great-gatsby/index.html\")\n    ) as f:\n        data = f.read()\n\n    assert data.strip() == fixtures[\"book.html\"].strip()\n\n\ndef test_rendered_page_from_data_file_without_slug():\n    with open(os.path.join(BASE_SITE_DIRECTORY, \"reviews/0/index.html\")) as f:\n        data = f.read()\n\n    assert data.strip() == fixtures[\"review.html\"].strip()\n\n\ndef test_rendered_page_from_template():\n    # also tests title interpolation\n    with open(os.path.join(BASE_SITE_DIRECTORY, \"index.html\")) as f:\n        data = f.read()\n\n    assert data.strip() == fixtures[\"index.html\"].strip()\n\n\ndef test_permalink_front_matter():\n    assert os.path.exists(os.path.join(BASE_SITE_DIRECTORY, \"book-list/index.html\"))\n\n\ndef test_rendered_page_with_logic():\n    # this also tests:\n    # - inheritance working (inheriting from `default`)\n    # - title interpolation working\n    with open(os.path.join(BASE_SITE_DIRECTORY, \"book-list/index.html\")) as f:\n        data = f.read()\n\n    assert data.replace(\" \", \"\").replace(\"\\n\", \"\") == fixtures[\n        \"book_list.html\"\n    ].replace(\" \", \"\").replace(\"\\n\", \"\")\n\n\ndef test_asset_copying():\n    with open(os.path.join(BASE_SITE_DIRECTORY, \"assets/styles.css\")) as f:\n        data = f.read()\n\n    assert data.strip() == fixtures[\"styles.css\"].strip()\n\n\ndef test_asset_copying_in_folders():\n    with open(os.path.join(BASE_SITE_DIRECTORY, \"assets/meta/robots.txt\")) as f:\n        data = f.read()\n\n    assert data.strip() == fixtures[\"robots.txt\"].strip()\n\n\ndef test_generate_blog_post():\n    with open(\n        os.path.join(BASE_SITE_DIRECTORY, \"2024/01/01/first-post/index.html\")\n    ) as f:\n        data = f.read()\n\n    assert data.strip() == fixtures[\"post.html\"].strip()\n\n\ndef test_new_site_generation():\n    os.system(\"aurora new test-site\")\n    assert os.path.exists(\"test-site\")\n    assert os.path.exists(\"test-site/assets\")\n    assert os.path.exists(\"test-site/pages\")\n    assert os.path.exists(\"test-site/pages/_layouts\")\n    assert os.path.exists(\"test-site/pages/_data\")\n    assert os.path.exists(\"test-site/pages/posts\")\n    assert os.path.exists(\"test-site/pages/templates/index.html\")\n\n    with open(\"test-site/config.py\") as f:\n        data = f.read()\n\n    assert data.strip() == fixtures[\"new_site_config.py\"].strip()\n\n    shutil.rmtree(\"test-site\")\n\n\ndef test_pre_generation_hook():\n    # this page uses {{ page.visitors }}, which is computed in a pre-generation hook\n    with open(os.path.join(BASE_SITE_DIRECTORY, \"about/index.html\")) as f:\n        data = f.read()\n\n    assert data.strip() == fixtures[\"about.html\"].strip()\n\n\ndef test_post_build_hook():\n    # check for presence of site/made-by.txt\n    assert os.path.exists(os.path.join(BASE_SITE_DIRECTORY, \"made-by.txt\"))\n\n\ndef test_year_date_archive_generation():\n    with open(os.path.join(BASE_SITE_DIRECTORY, \"2024/index.html\")) as f:\n        data = f.read()\n\n    assert data.strip().replace(\" \", \"\").replace(\"\\n\", \"\") == fixtures[\n        \"date_year.html\"\n    ].strip().replace(\" \", \"\").replace(\"\\n\", \"\")\n\n\ndef test_year_month_date_archive_generation():\n    with open(os.path.join(BASE_SITE_DIRECTORY, \"2024/01/index.html\")) as f:\n        data = f.read()\n\n    assert data.strip().replace(\" \", \"\").replace(\"\\n\", \"\") == fixtures[\n        \"date_year_month.html\"\n    ].strip().replace(\" \", \"\").replace(\"\\n\", \"\")\n\n\ndef test_year_month_day_date_archive_generation():\n    with open(os.path.join(BASE_SITE_DIRECTORY, \"2024/01/01/index.html\")) as f:\n        data = f.read()\n\n    assert data.strip().replace(\" \", \"\").replace(\"\\n\", \"\") == fixtures[\n        \"date_year_month_day.html\"\n    ].strip().replace(\" \", \"\").replace(\"\\n\", \"\")\n\n\ndef test_tag_archive_generation():\n    with open(os.path.join(BASE_SITE_DIRECTORY, \"tag/announcements/index.html\")) as f:\n        data = f.read()\n\n    assert data.strip().replace(\" \", \"\").replace(\"\\n\", \"\") == fixtures[\n        \"tag_archive.html\"\n    ].strip().replace(\" \", \"\").replace(\"\\n\", \"\")\n\n\ndef test_collection_pagination():\n    with open(os.path.join(BASE_SITE_DIRECTORY, \"rooms/index.html\")) as f:\n        data = f.read()\n\n    assert data.strip().replace(\" \", \"\").replace(\"\\n\", \"\") == fixtures[\n        \"collection_pagination.html\"\n    ].strip().replace(\" \", \"\").replace(\"\\n\", \"\")\n\n\ndef check_for_presence_of_state_file_after_build():\n    assert os.path.exists(\"state.json\")\n\n\ndef test_incremental_regeneration():\n    generated_files = []\n\n    for root, _, files in os.walk(\"_site\"):\n        for file in files:\n            generated_files.append(os.path.relpath(os.path.join(root, file), \"_site\"))\n\n    os.system(\"aurora build --incremental\")\n\n    new_generated_files = []\n\n    for root, _, files in os.walk(\"_site\"):\n        for file in files:\n            new_generated_files.append(\n                os.path.relpath(os.path.join(root, file), \"_site\")\n            )\n\n    assert set(generated_files) == set(new_generated_files)\n"
  }
]