Repository: capjamesg/aurora Branch: main Commit: 597037eee3c9 Files: 93 Total size: 167.5 KB Directory structure: gitextract_c93td2o_/ ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ └── bug-report.yml │ ├── dependabot.yml │ └── workflows/ │ ├── benchmark.yml │ ├── docs.yml │ ├── full-site-tests.yml │ ├── release.yml │ ├── test.yml │ └── welcome.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CITATION.cff ├── LICENSE ├── Makefile ├── README.md ├── aurora/ │ ├── __init__.py │ ├── cli.py │ ├── date_helpers.py │ ├── graph.py │ └── templates/ │ └── index.html ├── docs/ │ ├── assets/ │ │ ├── prism.css │ │ └── prism.js │ ├── config.py │ ├── highlighting.py │ ├── pages/ │ │ ├── _layouts/ │ │ │ └── default.html │ │ └── templates/ │ │ ├── 404.html │ │ ├── archives.md │ │ ├── blog.html │ │ ├── build-methods.md │ │ ├── collections-from-data.html │ │ ├── collections.md │ │ ├── configuration.html │ │ ├── dates.html │ │ ├── design.html │ │ ├── hooks.html │ │ ├── index.html │ │ ├── pagination.md │ │ ├── performance.html │ │ ├── permalinks.html │ │ ├── robots.html │ │ ├── sitemap.html │ │ ├── start.html │ │ ├── state.html │ │ ├── structure.html │ │ ├── templates.md │ │ ├── templating.md │ │ └── users.html │ └── state.json ├── requirements.txt ├── setup.py └── tests/ ├── fixtures/ │ ├── about.html │ ├── about_ISO-8859-1.html │ ├── about_UTF-16-BE.html │ ├── about_Windows-1252.html │ ├── book.html │ ├── book_list.html │ ├── category_archive.html │ ├── collection_pagination.html │ ├── date_year.html │ ├── date_year_month.html │ ├── date_year_month_day.html │ ├── index.html │ ├── new_site_config.py │ ├── post.html │ ├── review.html │ ├── robots.txt │ ├── styles.css │ └── tag_archive.html ├── library/ │ ├── assets/ │ │ ├── meta/ │ │ │ └── robots.txt │ │ └── styles.css │ ├── config.py │ ├── hooks.py │ ├── pages/ │ │ ├── _data/ │ │ │ ├── books.json │ │ │ └── reviews.csv │ │ ├── _layouts/ │ │ │ ├── book-template.html │ │ │ ├── category.html │ │ │ ├── date.html │ │ │ ├── default.html │ │ │ ├── post.html │ │ │ ├── reader-review.html │ │ │ ├── rooms.html │ │ │ └── tag.html │ │ ├── posts/ │ │ │ └── 2024-01-01-first-post.md │ │ ├── rooms/ │ │ │ ├── quiet-corner.html │ │ │ └── study-hall.html │ │ └── templates/ │ │ ├── about.html │ │ ├── about_ISO-8859-1.html │ │ ├── about_UTF-16-BE.html │ │ ├── about_Windows-1252.html │ │ ├── book_list.html │ │ └── index.html │ └── state.json └── state.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/bug-report.yml ================================================ name: Bug Report description: File a bug with Aurora. labels: [bug] body: - type: markdown attributes: value: | Thank you for submitting a bug report! - type: textarea attributes: label: Bug description: Provide a description of the bug you have encountered. If you are running into an error, please include the full error message. validations: required: true - type: textarea attributes: label: Minimal Reproducible Example description: > When asking a question, people will be better able to provide help if you provide code that they can easily understand and use to **reproduce** the problem. This is referred to by community members as creating a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example). placeholder: | ``` # Code to reproduce your issue here ``` validations: required: false - type: textarea attributes: label: Additional description: Anything else you would like to share? ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "daily" - package-ecosystem: "pip" directory: "/" schedule: interval: "daily" ================================================ FILE: .github/workflows/benchmark.yml ================================================ name: Run benchmark (200k pages+) on: workflow_dispatch jobs: build: runs-on: ${{ matrix.os }} strategy: matrix: os: ["ubuntu-latest", "macos-latest"] python-version: ["3.13"] steps: - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} cache: 'pip' - name: Install dependencies run: | python3 -m pip install --upgrade pip pip install -e . git clone https://github.com/capjamesg/aurora-hn-benchmark - name: Build main site env: SITE_ENV: ${{ secrets.SITE_ENV }} run: | cd aurora-hn-benchmark { time aurora build; } 2> time_output.txt echo "${{ matrix.os }} - Python ${{ matrix.python-version }}" > performance.txt echo "Commit: $(git rev-parse HEAD)" >> time_taken.txt cat time_output.txt | grep real | awk '{print $2}' >> performance.txt cat performance.txt ================================================ FILE: .github/workflows/docs.yml ================================================ name: Publish documentation on: push: branches: - main jobs: build: runs-on: ubuntu-latest strategy: matrix: python-version: [3.13] steps: - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} cache: 'pip' - name: Install dependencies run: | python3 -m pip install --upgrade pip pip install -e . pip install pygments bs4 lxml cd docs - name: Build main site env: SITE_ENV: ${{ secrets.SITE_ENV }} run: | cd docs aurora build - name: rsync deployments uses: burnett01/rsync-deployments@7.1.0 with: switches: -avzr path: "docs/_site/*" remote_path: ${{ secrets.SITE_PATH }} remote_host: ${{ secrets.SERVER_HOST }} remote_user: ${{ secrets.SERVER_USERNAME }} remote_key: ${{ secrets.KEY }} ================================================ FILE: .github/workflows/full-site-tests.yml ================================================ name: Test several sites built with Aurora on: push: branches: - main jobs: build: runs-on: ${{ matrix.os }} strategy: matrix: os: ["ubuntu-latest", "macos-latest"] python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} cache: 'pip' - name: Install dependencies run: | python3 -m pip install --upgrade pip pip install -e . - name: Build airport pianos env: SITE_ENV: ${{ secrets.SITE_ENV }} run: | git clone https://github.com/capjamesg/airport-pianos cd airport-pianos aurora build - name: Build train station pianos env: SITE_ENV: ${{ secrets.SITE_ENV }} run: | git clone https://github.com/capjamesg/train-station-pianos cd train-station-pianos aurora build - name: Build blog example env: SITE_ENV: ${{ secrets.SITE_ENV }} run: | git clone https://github.com/capjamesg/aurora-blog-template cd aurora-blog-template aurora build - name: Build docs example env: SITE_ENV: ${{ secrets.SITE_ENV }} run: | git clone https://github.com/capjamesg/aurora-docs-template cd aurora-docs-template aurora build ================================================ FILE: .github/workflows/release.yml ================================================ name: Publish WorkFlow on: release: types: [created] jobs: build: runs-on: ubuntu-latest strategy: matrix: python-version: [3.8] steps: - name: 🛎️ Checkout uses: actions/checkout@v6 with: ref: ${{ github.head_ref }} - name: 🐍 Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: 🦾 Install dependencies run: | python -m pip install --upgrade pip twine wheel - name: 🚀 Publish to PyPi env: PYPI_USERNAME: ${{ secrets.PYPI_USERNAME }} PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | python setup.py sdist bdist_wheel twine check dist/* twine upload dist/* -u ${PYPI_USERNAME} -p ${PYPI_PASSWORD} --verbose ================================================ FILE: .github/workflows/test.yml ================================================ name: Aurora Test Suite on: pull_request: branches: [main] push: branches: [main] jobs: build-dev-test: runs-on: ${{ matrix.os }} strategy: matrix: os: ["ubuntu-latest", "macos-latest"] python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - name: 🛎️ Checkout uses: actions/checkout@v6 - name: 🐍 Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} check-latest: true - name: 📦 Install dependencies run: | python -m pip install --upgrade pip pip install . pip install pytest - name: 🧪 Test env: SITE_ENV: production run: "python -m pytest ./tests/state.py" ================================================ FILE: .github/workflows/welcome.yml ================================================ name: Welcome on: [pull_request, issues] jobs: greeting: name: 👋 Welcome runs-on: ubuntu-latest steps: - uses: actions/first-interaction@v3 with: repo-token: ${{ secrets.GITHUB_TOKEN }} issue-message: "Thank you for creating an Issue on this repository! 🙌 We will get back to you shortly." pr-message: "Thank you for creating an PR on this repository! 🙌 We will get back to you shortly." ================================================ FILE: .gitignore ================================================ # env specific config.json .env # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class .idea # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST .gptexecthread # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ #config stuff tests/config.json tests/manual/data #dataset download stuff # test/ # train/ # valid/ # data.yaml README.roboflow.txt *.zip .DS_Store _site/* docs/_site/* tests/library/_site/* ================================================ FILE: .pre-commit-config.yaml ================================================ ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.0.4] 2024-05-28 * Add support for data files ## [0.0.2] 2024-05-28 * Fix bug where `page` data was not available in parent templates. * Add `pages` variable that can be used to see a list of all pages in a template. * Small performance improvements. * Fix missing `click` dependency. ## [0.0.1] 2024-05-27 Initial release of Aurora. ================================================ FILE: CITATION.cff ================================================ # This CITATION.cff file was generated with cffinit. # Visit https://bit.ly/cffinit to generate yours today! cff-version: 1.2.0 title: Aurora message: >- If you use this software, please cite it using the metadata from this file. type: software authors: - given-names: James email: jamesg@jamesg.blog repository-code: 'https://github.com/capjamesg/aurora' url: 'https://github.com/capjamesg/aurora' abstract: >- A static site generator implemented in Python. keywords: - static site generator - website license: MIT ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2024 James Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ .PHONY: style check_code_quality export PYTHONPATH = . check_dirs := aurora style: black $(check_dirs) isort --profile black $(check_dirs) check_code_quality: black --check $(check_dirs) isort --check-only --profile black $(check_dirs) # stop the build if there are Python syntax errors or undefined names flake8 $(check_dirs) --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. E203 for black, E501 for docstring, W503 for line breaks before logical operators flake8 $(check_dirs) --count --max-line-length=88 --exit-zero --ignore=D --extend-ignore=E203,E501,W503 --statistics publish: python3 -m build twine check dist/* twine upload dist/* -u ${PYPI_USERNAME} -p ${PYPI_PASSWORD} --verbose ================================================ FILE: README.md ================================================ ![Banner](banner.png)
[![version](https://badge.fury.io/py/aurora-ssg.svg)](https://badge.fury.io/py/aurora-ssg) [![downloads](https://img.shields.io/pypi/dm/aurora-ssg)](https://pypistats.org/packages/aurora-ssg) [![license](https://img.shields.io/pypi/l/aurora-ssg)](https://github.com/capjamesg/aurora-ssg/blob/main/LICENSE.md) [![python-version](https://img.shields.io/pypi/pyversions/aurora-ssg)](https://badge.fury.io/py/aurora-ssg) [![test workflow](https://github.com/capjamesg/aurora/actions/workflows/test.yml/badge.svg)](https://github.com/capjamesg/aurora/actions/workflows/test.yml)
# Aurora Aurora is a static site generator implemented in Python. [See a blog template that you can use with Aurora](https://github.com/capjamesg/aurora-blog-template). Aurora supports: - Creating content and pages with markdown, jinja2, and HTML - Static and incremental builds - Interactive building with hot reloading for development (up to < 300ms reload time) - Out-of-the-box support for generating date, category, and tag archive pages Aurora is supported on Linux and macOS. Aurora does not yet work on Windows, and there may be issues using Aurora on WSL. If you run into any issues with installation, please create a GitHub Issue. ## Demos ### Static Generation (1k+ pages) https://github.com/capjamesg/aurora/assets/37276661/59e4f3e6-f470-46bd-8812-0b475be40e88 ### Incremental Static Regeneration (~40 pages) https://github.com/capjamesg/aurora/assets/37276661/39f62bd8-cf5f-4d15-a325-7d433b7ceeb0 ## Get Started ### Install Aurora First, install Aurora: ```bash pip3 install aurora-ssg ``` ### Create a Site To create a new site, run the following command: ```bash aurora new my-site ``` This will create a folder called `my-site` with everything you need to start your Aurora site. To navigate to your site, run: ```bash cd my-site ``` Aurora sites contain a few directories by default: - `_layouts`: Store templates for your site. - `assets`: Store static files like images, CSS, and JavaScript. - `posts`: Store blog posts (optional). - `pages`: Store static pages to generate. A new Aurora site will come with a `pages/index.html` file that you can edit to get started. ### Build Your Site (Static) You can build your site into a static site by running the `aurora build` command. Aurora works relative to the directory you are in. To build your site, navigate run the following command: ```bash aurora build ``` This will generate your site in a `_site` directory. ### Build Your Site (Dynamic) For development purposes, you can run Aurora with a watcher that will automatically rebuild your site when you make changes to any page in your website. To run Aurora in watch mode, run the following command: ```bash aurora serve ``` Your site will be built in the `_site` directory. Any time you make a change to your templates, the `_site` directory will be updated to reflect those changes. ### Development Setup If you are interested in contributing to Aurora, you will need a local development setup. To set up your development environment, run the following commands: ```bash git clone https://github.com/capjamesg/aurora cd aurora pip3 install -e . ``` This will install Aurora in editable mode. In editable mode, you can make changes to the code and see them reflected in your local installation. ## Aurora Site Structure By default, an Aurora site has the following structure in the root directory: - `pages`: Where all pages used to generate your site are stored. - `pages/_layouts`: Where you can store layouts for use in generating your website. - `pages/_data`: Where you can store JSON data files for use in generating pages. See the "Render Collections of Data" section later in this document for information on how to use this directory to generate pages from data files. - `pages/posts`: Where you can store all of your blog posts, if you use your site as a blog. The posts directory is processed with additional logic to automatically generate date archive and category archive pages, if applicable. Any file in `pages` or a folder you make in `pages` (not including `_layouts` and `_data`) will be rendered on your website. For example, if you create a `pages/interests/coffee.html` file, this will generate a page called `_site/pages/interests/coffee/index.html`. ## Configuration You need a `config.py` file in the directory in which you will build your Aurora site. This file is automatically generated when you run `aurora new [site-name]`. This configuration file defines a few values that Aurora will use when processing your website. Here is the default `config.py` file, with accompanying comments: ```python import os BASE_URLS = { "local": os.getcwd(), } SITE_ENV = os.environ.get("SITE_ENV", "local") BASE_URL = BASE_URLS[SITE_ENV] ROOT_DIR = "pages" # where your site pages are LAYOUTS_BASE_DIR = "_layouts" # where your site layouts are stored SITE_DIR = "_site" # the directory in which your site will be saved REGISTERED_HOOKS = {} # used to register hooks (see `Build Hooks (Advanced)` documentation below for details) ``` The `BASE_URLS` dictionary is used to define the base URL for your site. This is useful if you want to maintain multiple environments for your site (e.g., local, staging, production). Here is an example configuration of a site that has a local and staging environment: ```python BASE_URLS = { "production": "https://jamesg.blog", "staging": "https://staging.jamesg.blog", "local": os.getcwd(), } ``` ## Render Collections of Data You can render data from JSON files as web pages with Aurora. This is useful if you have a JSON collection of data, such as a list of coffee shop reviews, that you want to turn into posts without creating corresponding markdown files. To create a collection, add a new file to your site's `pages/_data` directory. This file should have a `.json` extension. Within the file, create a list that contains JSON objects, like this: ```json [ {"slug": "rosslyn-coffee", "layout": "coffee", "title": "Rosslyn Coffee in London is terrific."} ] ``` This file is called `pages/_data/coffee.json`. Every entry must have a `layout` key. This corresponds with the name of the template that will be used to render the page. For example, the `coffee` layout will be rendered using the `pages/_layouts/coffee.html` template. We need to create the `pages/_layouts/coffee.html` template to render our collection. Create a new file called `pages/_layouts/coffee.html` and add the following contents: ``` --- title: Coffee List --- {% for item in site.coffee %} {{ item.title }} {% endfor %} ``` Every entry must also have a `slug` key. This corresponds with the name of the page that will be generated. In the case above, one file will be created in the `_site` output directory: `_site/coffee/rosslyn-coffee/index.html`. ## Build Hooks (Advanced) You can define custom functions that are run before a file is processed by Aurora. You can use this feature to save metadata about a page that can then be consumed by a template. These functions are called "hooks". To define a hook, you need to: 1. Write a hook function with the right type signature, and; 2. Add the hook function to the `HOOKS` dictionary in your `config.py` file. For example, you could define a function that saves the word count of a page: ```python def word_count_hook(file_name: str, page_state: dict, site_state: dict): if "posts/" not in file_name: return page_state page_state["word_count"] = len(page_state["content"].split()) return page_state ``` Suppose this is saved in a file called `hooks.py`. This function would make a `page.word_count` available in the page on which it is run. Hooks **must** return the `page_state` dictionary, otherwise the page cannot be processed correctly. To register a hook, create an entry in the `REGISTERED_HOOKS` dictionary in your `config.py` file: ```python REGISTERED_HOOKS = { "hooks": ["word_count_hook"], } ``` Above, `hooks` corresponds to the name of the Python file with our hook, relative to the directory in which `aurora build` is run. (NB: `aurora build` should always be run in the root directory of your Aurora site.) `word_count_hook` is the name of the function we defined in `hooks.py`. You can define as many hooks as you want. To register multiple hooks in the same file, use the syntax: ```python REGISTERED_HOOKS = { "hook_file_name": ["hook1", "hook2", "hook3"], } ``` ## Test Suite To run the Aurora tests, run: ``` pytest tests/*.py ``` ## Performance In a test generating 292,884 files from a CSV file with a single layer of inheritance in each template, Aurora built the website in 140.59 seconds (2m:20s). In a test on a website with 1,763 files and multiple layers of inheritance, Aurora built the website in 3.149s. The files in this test were a combination of blog posts, static pages, and programmatic archives for blog posts (date pages, category pages). In a test rendering 4,000 markdown files with a single layer of inheritance in each template, Aurora built the website in between 0.9 and 1.2 seconds. In a test comparing 11ty to Aurora in generating the [Airport Pianos](https://github.com/capjamesg/airport-pianos) website (~45 pages), 11ty took 1.36 seconds to start and generate the site, whereas Aurora took 0.034 seconds. ## Users The following sites are built with Aurora: - [James' Coffee Blog](https://jamesg.blog) (1,500+ pages) - [Airport Pianos](https://airportpianos.org) (~45 pages) - [Train Station Pianos](https://trainstationpianos.org) (~20 pages) Have you made a website with Aurora? File a PR and add it to the list! ## License This project is licensed under an [MIT license](LICENSE). ================================================ FILE: aurora/__init__.py ================================================ __version__ = "0.1.7" ================================================ FILE: aurora/cli.py ================================================ import os import click from . import __version__ @click.group() @click.version_option(version=__version__) def main(): pass @click.command("new") @click.argument("name") def new(name): cli_dir = os.path.dirname(os.path.realpath(__file__)) if os.path.exists(name): print("Site already exists.") return os.makedirs(name) os.chdir(name) with open("config.py", "w") as f: f.write( """import os BASE_URLS = { "local": os.getcwd(), "production": "https://example.com", } SITE_ENV = os.environ.get("SITE_ENV", "local") BASE_URL = BASE_URLS[SITE_ENV] ROOT_DIR = "pages" LAYOUTS_BASE_DIR = "_layouts" SITE_DIR = "_site" HOOKS = {} SITE_STATE = {} """ ) os.makedirs("pages") os.makedirs("assets") os.chdir("pages") os.makedirs("_layouts") os.makedirs("_data") os.makedirs("posts") os.makedirs("templates/") with open("templates/index.html", "w") as f: with open(os.path.join(cli_dir, "templates", "index.html")) as index: f.write(index.read()) os.chdir("..") print(f"Site {name} created. ✨") print("Run cd/into the site directory.") print("Then, `aurora build` to build the site.") print("You can also `aurora serve` to start a local server.") @click.command("build") @click.option("--incremental", is_flag=True) def build(incremental): from .graph import main as build_site # import cProfile # cProfile.run("build_site(incremental=incremental)", sort="cumulative") print("Building site...") build_site(incremental=incremental) print("Done! ✨") @click.command("serve") def serve(): from .graph import main as build_site build_site(watch=True) main.add_command(new) main.add_command(build) main.add_command(serve) ================================================ FILE: aurora/date_helpers.py ================================================ import datetime import dateutil.parser def month_number_to_written_month(month): return datetime.datetime.strptime(str(month), "%m").strftime("%B") def list_archive_date(date): if isinstance(date, str): date = dateutil.parser.parse(date) return date def long_date(date): return list_archive_date(date).strftime("%B %d, %Y") def date_to_xml_string(date): return list_archive_date(date).strftime("%Y-%m-%dT%H:%M:%S") def archive_date(date): return list_archive_date(date).strftime("%Y/%m") def year(date): return list_archive_date(date).strftime("%Y") ================================================ FILE: aurora/graph.py ================================================ import logging import os import sys if not os.path.exists("config.py"): raise Exception("config.py not found") import csv import datetime import hashlib import json import re from copy import deepcopy import chardet import orjson import pyromark import tqdm from frontmatter import loads from bs4 import BeautifulSoup from jinja2 import ( Environment, FileSystemBytecodeCache, FileSystemLoader, Template, meta, nodes, ) from jinja2.visitor import NodeVisitor from toposort import toposort_flatten from yaml.reader import ReaderError from collections import defaultdict from .date_helpers import ( archive_date, date_to_xml_string, list_archive_date, long_date, month_number_to_written_month, year, ) module_dir = os.getcwd() os.chdir(module_dir) sys.path.append(module_dir) state_to_write = {} original_file_to_permalink = {} normalized_collection_permalinks = {} # print all logs logging.basicConfig(level=logging.INFO) from config import ( BASE_URL, HOOKS, LAYOUTS_BASE_DIR, ROOT_DIR, SITE_DIR, SITE_STATE, SITE_ENV, ) ALLOWED_EXTENSIONS = ["html", "md", "css", "js", "txt", "xml"] saved_pages = set() permalinks = defaultdict(list) all_data_files = {} all_pages = [] all_opened_pages = {} all_page_contents = {} collections_to_files = {} all_dependencies = {} all_parsed_pages = {} dates = set() years = {} reverse_deps = {} collection_permalinks_to_idx = {} layout_permalinks_to_idx = {} # ensures a single template cannot have more than 10 levels of inheritance INHERITANCE_LIMIT = 10 DATA_FILES_DIR = os.path.join(ROOT_DIR, "_data") EVALUATED_REGISTERED_TEMPLATE_GENERATION_HOOKS = {} EVALUATED_POST_TEMPLATE_GENERATION_HOOKS = {} EVALUATED_POST_BUILD_HOOKS = {} class Post: def __init__(self, front_matter): self.__dict__.update(front_matter) def __getattr__(self, name): return self.__dict__.get(name) def serialize_as_json(self): """Serialize the Post object as JSON.""" return orjson.dumps(self.__dict__).decode() for file_name, hooks in HOOKS.get("pre_template_generation", {}).items(): EVALUATED_REGISTERED_TEMPLATE_GENERATION_HOOKS[file_name] = [ getattr(__import__(file_name), func) for func in hooks ] for file_name, hooks in HOOKS.get("post_template_generation", {}).items(): EVALUATED_POST_TEMPLATE_GENERATION_HOOKS[file_name] = [ getattr(__import__(file_name), func) for func in hooks ] for file_name, hooks in HOOKS.get("post_build", {}).items(): EVALUATED_POST_BUILD_HOOKS[file_name] = [ getattr(__import__(file_name), func) for func in hooks ] today = datetime.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) state = { "posts": [], "backlinks": defaultdict(list), "root_url": BASE_URL, "build_date": today.strftime("%m-%d"), "pages": [], "build_timestamp": datetime.datetime.now().isoformat(), "environment": SITE_ENV, } file_extensions = {} state.update(SITE_STATE) JINJA2_ENV = Environment( loader=FileSystemLoader(ROOT_DIR), bytecode_cache=FileSystemBytecodeCache(), ) JINJA2_ENV.filters["long_date"] = long_date JINJA2_ENV.filters["date_to_xml_string"] = date_to_xml_string JINJA2_ENV.filters["archive_date"] = archive_date JINJA2_ENV.filters["list_archive_date"] = list_archive_date JINJA2_ENV.filters["month_number_to_written_month"] = month_number_to_written_month JINJA2_ENV.filters["year"] = year for file_name, hooks in HOOKS.get("template_filters", {}).items(): for hook in hooks: JINJA2_ENV.filters[hook] = getattr(__import__(file_name), hook) md = pyromark.Markdown( options=( pyromark.Options.ENABLE_FOOTNOTES | pyromark.Options.ENABLE_SMART_PUNCTUATION | pyromark.Options.ENABLE_HEADING_ATTRIBUTES ) ) def read_file(file_name, mode="r") -> str: """ Read a file and return its contents. """ try: with open(file_name, mode) as file: return file.read() except UnicodeDecodeError as e: raw_data = open(file_name, "rb").read() result = chardet.detect(raw_data) encoding = result["encoding"] with open(file_name, "rb") as file: return file.read().decode(encoding) except Exception as e: print(f"Error reading {file_name}") raise e def slugify(value: str) -> str: """ Turn a string into a slug for use in saving data to a file. """ return value.lower().strip().replace(" ", "-") class VariableVisitor(NodeVisitor): """ Find all variables in a jinja2 template. """ def __init__(self): self.variables = set() def visit_Name(self, node, *args, **kwargs) -> None: self.variables.add(node.name) self.generic_visit(node, *args, **kwargs) def visit_Getattr(self, node, *args, **kwargs) -> None: current_node = node variable_chain = [] while isinstance(current_node, nodes.Getattr): variable_chain.append(current_node.attr) current_node = current_node.node if isinstance(current_node, nodes.Name): variable_chain.append(current_node.name) full_variable = ".".join(reversed(variable_chain)) self.variables.add(full_variable) self.generic_visit(node, *args, **kwargs) def get_file_dependencies_and_evaluated_contents( file_name: str, contents: Template ) -> tuple: """ Get all dependencies of a file. Dependencies are: 1. Other files that are included in the file, and; 2. Variables whose values are defined by the site generator (i.e. `site.*`). """ template = JINJA2_ENV.parse(all_page_contents[file_name]) includes = [] included_variables = [] for node in meta.find_referenced_templates(template): includes.append(node) visitor = VariableVisitor() visitor.visit(template) for var in visitor.variables: included_variables.append(var) dependencies = set() for include in includes: if isinstance(include, str): dependencies.add(os.path.join(ROOT_DIR, include)) else: dependencies.add(os.path.join(ROOT_DIR, include.template.value)) for variable in included_variables: variable = variable.replace("site.", "") for collection in collections_to_files: if collections_to_files.get(collection): dependencies.update(collections_to_files[collection]) if variable in state: dependencies.add(variable) parsed_content = all_page_contents[file_name] if not parsed_content.get("slug"): parsed_content["slug"] = file_name.split("/")[-1].replace(".html", "") parsed_content["contents"] = md.html(parsed_content.content) parsed_content["url"] = f"{BASE_URL}/{file_name.replace(ROOT_DIR + '/posts/', '')}" if parsed_content.metadata.get("permalink") and parsed_content.metadata["permalink"].startswith("/"): parsed_content["has_user_assigned_permalink"] = True parsed_content["permalink"] = f"/{parsed_content.metadata['permalink'].strip('/')}/" parsed_content[ "permalink" ] = f"/{parsed_content.metadata.get('permalink', parsed_content['slug']).strip('/')}/" if "categories" not in parsed_content: parsed_content["categories"] = [] slug = file_name.split("/")[-1].replace(".html", "") slug = slug.replace("posts/", "") if slug[0].isdigit(): date_slug = re.search(r"\d{4}-\d{2}-\d{2}", slug) if date_slug: date_slug = date_slug.group(0) if not parsed_content.get("post"): parsed_content["post"] = {} if not parsed_content.get("page"): parsed_content["page"] = {} parsed_content["post"]["date"] = datetime.datetime.strptime( date_slug, "%Y-%m-%d" ) # if "hms" in post metadata, add to post date if parsed_content.get("hms") and parsed_content["hms"].count(":") == 2: parsed_content["post"]["date"] = parsed_content["post"]["date"].replace( hour=int(parsed_content["hms"].split(":")[0]), minute=int(parsed_content["hms"].split(":")[1]), second=int(parsed_content["hms"].split(":")[2]), ) parsed_content["post"]["date_without_year"] = parsed_content["post"][ "date" ].strftime("%m-%d") parsed_content["date_without_year"] = parsed_content["post"][ "date_without_year" ] parsed_content["post"]["full_date"] = parsed_content["post"][ "date" ].strftime("%B %d, %Y") parsed_content["date"] = parsed_content["post"]["date"] parsed_content["page"]["date"] = parsed_content["post"]["date"] if "description" not in parsed_content: parsed_content["description"] = md.html( parsed_content.content.split("\n")[0] ) date_slug = date_slug.replace("-", "/") slug_without_date = re.sub(r"\d{4}-\d{2}-\d{2}-", "", slug) parsed_content["post"][ "url" ] = f"{BASE_URL}/{date_slug}/{slug_without_date.replace('.html', '').replace('.md', '')}/" if "layout" in parsed_content: dependencies.add( f"{ROOT_DIR}/{LAYOUTS_BASE_DIR}/{parsed_content['layout']}.html" ) if not state.get(parsed_content["layout"] + "s"): state[parsed_content["layout"] + "s"] = [] if not layout_permalinks_to_idx.get(parsed_content["permalink"]): state[parsed_content["layout"] + "s"].append(parsed_content) layout_permalinks_to_idx[parsed_content["permalink"]] = ( len(state[parsed_content["layout"] + "s"]) - 1 ) else: if ( len(state[parsed_content["layout"] + "s"]) > layout_permalinks_to_idx[parsed_content["permalink"]] ): state[parsed_content["layout"] + "s"][ layout_permalinks_to_idx[parsed_content["permalink"]] ] = parsed_content if "collection" in parsed_content: collection_normalized = parsed_content["collection"].lower() if not state.get(collection_normalized): state[collection_normalized] = [] if not normalized_collection_permalinks.get(collection_normalized): normalized_collection_permalinks[collection_normalized] = [] # if permalink in collection_permalinks_to_idx, replace if collection_permalinks_to_idx.get(parsed_content["permalink"]): state[collection_normalized][ collection_permalinks_to_idx[parsed_content["permalink"]] ] = parsed_content else: state[collection_normalized].append(parsed_content) collection_permalinks_to_idx[parsed_content["permalink"]] = state[ collection_normalized ].index(parsed_content) return dependencies, parsed_content def make_any_nonexistent_directories(path: str) -> None: if not os.path.exists(path): os.makedirs(path) def interpolate_front_matter(front_matter: dict, state: dict, runtime = None) -> dict: """Evaluate front matter with Jinja2 to allow logic in front matter.""" # Keep track of already interpolated keys to prevent double interpolation interpolated_keys = set() for key in front_matter.keys(): if ( isinstance(front_matter[key], str) and "{" in front_matter[key] and key != "contents" and (not front_matter.get("defer_title_evaluation") or runtime == "category") and key not in interpolated_keys # Only interpolate if key hasn't been processed ): try: front_matter[key] = JINJA2_ENV.from_string(front_matter[key]).render( page=front_matter.get("page", front_matter), site=state ) interpolated_keys.add(key) # Mark this key as interpolated except: print(f"Error evaluating {front_matter[key]}. ERROR.") continue return front_matter def recursively_build_page_template_with_front_matter( file_name: str, front_matter: dict, state: dict, current_contents: str = "", level: int = 0, original_page: dict = None, ) -> str: """ Recursively build a page template with front matter. This function is called recursively until there is no layout key in the front matter. """ if level > 10: logging.critical( f"{file_name} has more than ten levels of recursion. Template will be marked as empty." ) return "" if front_matter and "layout" in front_matter.metadata: layout = front_matter.metadata["layout"] layout_path = f"{ROOT_DIR}/{LAYOUTS_BASE_DIR}/{layout}.html" # Always use a deep copy of the current front matter for this recursion current_page_metadata = deepcopy(front_matter.metadata) # Interpolate front matter before creating page object current_page_metadata = interpolate_front_matter(current_page_metadata, state) # Create page object with interpolated front matter page_fm = type("Page", (object,), current_page_metadata)() current_contents = loads( all_opened_pages[layout_path].render( page=page_fm, site=state, content=current_contents, post=Post(current_page_metadata), ) ).content layout_front_matter = all_parsed_pages[layout_path] # Pass the current page metadata for the next recursion layout_front_matter["page"] = current_page_metadata layout_front_matter["post"] = current_page_metadata return recursively_build_page_template_with_front_matter( file_name, layout_front_matter, state, current_contents.strip(), level + 1, current_page_metadata ) return current_contents def render_page(file: str, skip_hooks=False) -> None: """ Render a page with the Aurora static site generator. """ original_file = file # # skip if json if file.endswith(".json"): return try: contents = all_opened_pages[file] except Exception as e: print(f"Error reading {file}") # raise e return page_state = state.copy() has_user_assigned_permalink = False if all_parsed_pages[file].get("skip"): return slug = file.split("/")[-1].replace(".html", "") slug = slug.replace("posts/", "") has_user_assigned_permalink = all_parsed_pages[file].metadata.get( "has_user_assigned_permalink" ) page_state["page"] = all_parsed_pages[file].metadata page_state["post"] = all_parsed_pages[file].metadata if not page_state["page"].get("permalink"): page_state["page"]["permalink"] = page_state.get("permalink", slug) # .strip("/") page_state["page"]["generated_on"] = datetime.datetime.now() if slug[0].isdigit(): date_slug = re.search(r"\d{4}-\d{2}-\d{2}", slug) if date_slug: date_slug = date_slug.group(0) page_state["post"]["date"] = datetime.datetime.strptime( date_slug, "%Y-%m-%d" ) if page_state["post"].get("hms") and page_state["post"]["hms"].count(":") == 2: page_state["post"]["date"] = page_state["post"]["date"].replace( hour=int(page_state["post"]["hms"].split(":")[0]), minute=int(page_state["post"]["hms"].split(":")[1]), second=int(page_state["post"]["hms"].split(":")[2]), ) page_state["post"]["full_date"] = page_state["post"]["date"].strftime( "%B %d, %Y" ) page_state["date"] = page_state["post"]["date"] page_state["full_date"] = page_state["post"]["full_date"] if "description" not in page_state["post"]: page_state["post"]["description"] = all_parsed_pages[ file ].content.split("\n")[0] page_state["is_article"] = True if page_state.get("date"): date = page_state["date"] slug = re.sub(r"\d{4}-\d{2}-\d{2}-", "", file) slug = slug.replace("pages/posts/", "").replace(".md", "").replace(".html", "") page_state["page"]["slug"] = slug page_state["page"]["url"] = f"{BASE_URL}/{date.strftime('%Y/%m/%d')}/{slug}/" else: page_state["page"]["url"] = f"{BASE_URL}/{slug}/" page_state["url"] = page_state["page"]["url"] if file == "pages/templates/index.html": page_state["url"] = BASE_URL page_state["page"]["url"] = BASE_URL page_state["page"]["permalink"] = BASE_URL if not page_state.get("categories"): page_state["categories"] = [] state["categories"] = [] page_state["page"]["generated_from"] = file if page_state.get("page"): page_state["page"] = type("Page", (object,), page_state["page"])() page_state["post"] = Post(page_state["page"].__dict__) for hook, hooks in EVALUATED_REGISTERED_TEMPLATE_GENERATION_HOOKS.items(): for hook in hooks: page_state = hook(file, page_state, state) try: if file.endswith(".md"): contents = md.html(loads(all_opened_pages[file]).content) elif isinstance(contents, str): # this happens for data files only, where content does not exist contents = "" else: contents = loads(contents.render(page=page_state, site=state)).content except Exception as e: # print(f"Error rendering {file}") return page_state["page"].template = file rendered = recursively_build_page_template_with_front_matter( file, all_parsed_pages[file], page_state, contents ) if not skip_hooks: for _, hooks in EVALUATED_POST_TEMPLATE_GENERATION_HOOKS.items(): for hook in hooks: rendered = hook(file, page_state, state, rendered) file = file.replace(ROOT_DIR + "/", "") if page_state.get("date"): file = os.path.join(date.strftime("%Y/%m/%d"), f"{slug}", "index.html") if file.endswith(".md"): file = file[:-3] + ".html" permalink = file # if permalink is _site/templates/index.html, make it _site/index.html if file == "templates/index.html": path = os.path.join(SITE_DIR, "index.html") if os.path.exists(path): os.remove(path) with open(path, "w") as f: f.write(rendered) return if file.startswith("templates/") and any( file.endswith(ext) for ext in [".html", ".md"] ): if hasattr(page_state["page"], "permalink"): permalink = os.path.join( page_state["page"].permalink.strip("/"), "index.html" ) else: permalink = file.replace("templates/", "") elif has_user_assigned_permalink: permalink = os.path.join(page_state["page"].permalink.strip("/"), "index.html") else: permalink = file.replace("templates/", "") permalink_without_index = permalink.split("index.html")[0] final_url = f"{BASE_URL}/{permalink_without_index.rstrip('/')}/" if final_url not in saved_pages: # if has collections state["pages"].append( { "url": final_url, "file": file, "rendered_html": contents, "noindex": True if hasattr(page_state.get("page"), "noindex") else False, "private": True if hasattr(page_state.get("page"), "private") else False, "title": ( page_state["page"].title if page_state.get("page") and hasattr(page_state["page"], "title") else "" ), "collections": ( page_state["page"].collections if hasattr(page_state["page"], "collections") else "" ), "modified": os.path.getctime(original_file) if os.path.exists(original_file) else 0, } ) saved_pages.add(final_url) permalinks[permalink].append(file) permalink = os.path.join(SITE_DIR, permalink) if permalink.endswith(".html"): make_any_nonexistent_directories(os.path.dirname(permalink)) else: make_any_nonexistent_directories(os.path.join(SITE_DIR)) state_to_write[permalink] = rendered original_file_to_permalink[permalink] = original_file def generate_date_page_given_year_month_date( ymd_slug, posts, current_date_of_archive, granularity ) -> None: ymd_path = os.path.join(SITE_DIR, ymd_slug) make_any_nonexistent_directories(ymd_path) date_archive_layout = f"{ROOT_DIR}/{LAYOUTS_BASE_DIR}/date.html" if not all_opened_pages.get(date_archive_layout): return date_archive_contents = all_opened_pages[date_archive_layout] date_archive_state = state.copy() date_archive_state["date"] = current_date_of_archive page = deepcopy(all_parsed_pages[date_archive_layout]) page["date"] = current_date_of_archive date_archive_state["date_type"] = granularity date_archive_state["posts"] = [all_parsed_pages[post].metadata for post in posts] # order by date date_archive_state["posts"] = sorted( date_archive_state["posts"], key=lambda x: x["date"], reverse=True, ) fm = interpolate_front_matter(page, date_archive_state) rendered_page = date_archive_contents.render( date_archive_state, site=state, posts=date_archive_state["posts"], page=date_archive_state, ) if not date_archive_state.get("page"): date_archive_state["page"] = {} date_archive_state["page"]["template"] = date_archive_layout rendered_page = recursively_build_page_template_with_front_matter( ymd_path, fm, date_archive_state, loads(rendered_page).content ) with open( os.path.join(ymd_path, "index.html"), "wb", buffering=500, ) as f: f.write(rendered_page.encode()) def generate_paginated_page_for_collection( collection: str, per_page: int, template: str ) -> None: """ Generate paginated pages for a collection. """ if not state.get(collection): return print(f"Generating paginated pages for {collection}") collection = state[collection] if not collection: return all_keys_contain_dates = all(i.metadata.get("date") for i in collection) # if all keys have dates if all_keys_contain_dates: collection = sorted( collection, key=lambda x: x.metadata.get("date"), reverse=True ) else: collection = sorted( collection, key=lambda x: x.metadata.get("title"), reverse=True ) for i in tqdm.tqdm(range(0, len(collection), per_page)): page = i // per_page + 1 paginated_collection = collection[i : i + per_page] print(f"Generating paginated page {page} for {collection}") if page == 1: paginated_collection_path = os.path.join(SITE_DIR, f"{template}/index.html") else: paginated_collection_path = os.path.join( SITE_DIR, f"{template}/{page}/index.html" ) make_any_nonexistent_directories(os.path.dirname(paginated_collection_path)) paginated_collection_layout = f"{ROOT_DIR}/{LAYOUTS_BASE_DIR}/{template}.html" paginated_collection_contents = all_opened_pages[paginated_collection_layout] paginated_collection_state = state.copy() paginated_collection_state[collection[0]["layout"]] = paginated_collection paginated_collection_state["current_page"] = paginated_collection paginated_collection_state["page_number"] = page page = deepcopy(all_parsed_pages[paginated_collection_layout]) page[collection[0]["layout"]] = paginated_collection fm = interpolate_front_matter(page, paginated_collection_state) rendered_page = paginated_collection_contents.render( paginated_collection_state, site=state, posts=paginated_collection, page=paginated_collection_state, ) if not paginated_collection_state.get("page"): paginated_collection_state["page"] = {} paginated_collection_state["page"]["template"] = paginated_collection_layout rendered_page = recursively_build_page_template_with_front_matter( paginated_collection_path, fm, paginated_collection_state, loads(rendered_page).content, ) with open( paginated_collection_path, "wb", buffering=500, ) as f: f.write(rendered_page.encode()) def process_date_archives() -> None: """ Generate date archives for all posts. For example, if there are posts on 2022-01-01 and 2022-01-02, generate: - /2022/index.html - /2022/01/index.html - /2022/01/01/index.html """ posts = [ key for key in all_opened_pages.keys() if key.startswith(os.path.join(ROOT_DIR, "posts")) ] dates = set() years = {} for post in posts: if not hasattr(all_parsed_pages[post], "metadata"): continue if not all_parsed_pages[post].metadata.get("date"): continue date = all_parsed_pages[post].metadata["date"] dates.add(date) if date.year not in years: years[date.year] = {} if date.month not in years[date.year]: years[date.year][date.month] = {} if date.day not in years[date.year][date.month]: years[date.year][date.month][date.day] = [] years[date.year][date.month][date.day].append(post) for year in years: make_any_nonexistent_directories(os.path.join(SITE_DIR, str(year))) for month in years[year]: make_any_nonexistent_directories( os.path.join(SITE_DIR, str(year), str(month)) ) for day in years[year][month]: ymd_slug = f"{year}/{str(month).zfill(2)}/{str(day).zfill(2)}" generate_date_page_given_year_month_date( ymd_slug, years[year][month][day], datetime.datetime(year, month, day), "day", ) all_posts_in_month = [ post for day in years[year][month] for post in years[year][month][day] ] generate_date_page_given_year_month_date( f"{year}/{str(month).zfill(2)}", all_posts_in_month, datetime.datetime(year, month, 1), "month", ) all_posts_in_year = [ post for month in years[year] for day in years[year][month] for post in years[year][month][day] ] generate_date_page_given_year_month_date( str(year), all_posts_in_year, datetime.datetime(year, 1, 1), "year" ) print(f"Generated date archives for {year}") state["years"] = years def process_archives(name: str, state_key_associated_with_name: str, path: str): """ Generate category archives for all posts. For example, if you have a post with the `category` key set to `writing`, generate: - /writing/index.html """ categories = set() for post in state["posts"]: if not post.get(state_key_associated_with_name): continue for category in post[state_key_associated_with_name]: categories.add(category) for category in categories: make_any_nonexistent_directories( os.path.join(SITE_DIR, path, slugify(category)) ) archive_layout = f"{ROOT_DIR}/{LAYOUTS_BASE_DIR}/{name}.html" archive_contents = all_opened_pages[archive_layout] archive_state = state.copy() archive_state[name] = category page = deepcopy(all_parsed_pages[archive_layout]) page[name] = category archive_state["posts"] = [ post for post in state["posts"] if category in post.get(state_key_associated_with_name, []) ] print(f"Generating archive for {category}") page["category"] = category fm = interpolate_front_matter(page, archive_state, "category") fm["url"] = f"{BASE_URL}/{path}/{slugify(category)}/" rendered_page = archive_contents.render( archive_state, site=state, posts=archive_state["posts"], page=archive_state, ) if not archive_state.get("page"): archive_state["page"] = {} archive_state["page"]["template"] = archive_layout rendered_page = recursively_build_page_template_with_front_matter( archive_layout, fm, archive_state, loads(rendered_page).content, ) with open( os.path.join(SITE_DIR, path, slugify(category), "index.html"), "wb", buffering=500, ) as f: f.write(rendered_page.encode()) def copy_asset_to_site(assets: list) -> None: """ Copy an asset from the `assets` directory to the `_site/assets` directory. """ assets = [asset.replace("./assets/", "") for asset in assets] for a in assets: print(f"Copying {a} to _site/assets/{a}") make_any_nonexistent_directories(os.path.join(SITE_DIR, "assets")) asset = read_file(os.path.join("assets", a), "rb") with open(os.path.join(SITE_DIR, "assets", a), "wb") as f2: f2.write(asset) def get_state_from_last_build() -> dict: """ Get the state from the last build. """ try: data = json.load(open("state.json", "r")) except Exception as e: print("Error reading state.json. Running a full build.") return {} return data def calculate_dependencies_from_saved_state(all_dependencies: dict) -> list: """ Read the saved state and compute dependencies of files that have changed since the last build. """ deps = [] last_build = datetime.datetime.strptime( get_state_from_last_build().get("last_build"), "%Y-%m-%dT%H:%M:%S.%f" ) for root, dirs, files in os.walk(ROOT_DIR): # add if has changed since last build for file in files: path = os.path.join(root, file) # must be of parsable extension if os.path.splitext(file)[-1].replace(".", "") not in ALLOWED_EXTENSIONS: continue if os.path.getmtime(path) > last_build.timestamp(): print( f"Detected change in {path}. Rebuilding this page and its dependencies." ) dependencies_of_dependencies = [ i for i in all_dependencies if path in all_dependencies[i] ] + [path] deps.extend(dependencies_of_dependencies) return deps def load_data_from_data_files(deps: list, data_file_integrity: dict) -> list: """ Read all data files and create YAML file that can be used to generate pages. """ changed_files = [] for data_file in all_data_files: data_dir = data_file.replace(".json", "").replace(".csv", "") collections_to_files[data_dir] = [] idx = 0 print(f"Loading data from {data_file}...") for record in tqdm.tqdm(all_data_files[data_file]): if not record.get("slug"): # print( # f"Note: {data_file} {record} does not have a 'slug' key. Assigning substitute ID." # ) record["slug"] = str(idx) idx += 1 if not record.get("layout"): record["layout"] = data_dir slug = record.get("slug") path = os.path.join(data_dir, "index.html") record_as_string = orjson.dumps(record).decode() if ( data_file_integrity.get(slug) != hashlib.sha1(record_as_string.encode()).hexdigest() ): changed_files.append(path) data_file_integrity[slug] = hashlib.sha1( record_as_string.encode() ).hexdigest() try: contents = "---\n" + record_as_string + "\n---\n" loaded_contents = loads(contents) loaded_contents["skip"] = data_dir in SITE_STATE.get( "disable_collection_single_page_generation", {} ) if "body" in loaded_contents: loaded_contents["content"] = loaded_contents["body"] del loaded_contents["body"] all_opened_pages[path] = contents all_page_contents[path] = loaded_contents all_parsed_pages[path] = loaded_contents collections_to_files[data_dir].append(path) except ReaderError as e: print( f"Error reading {data_file} {record}. This page will not be generated.", ) # delete from all_page_contents all_page_contents.pop(path, None) all_opened_pages.pop(path, None) all_opened_pages.pop(path, None) continue return changed_files def get_data_files_in_folder(folder: str) -> list: folder = os.path.abspath(folder) # Convert to absolute path once files = [] for entry in os.listdir(folder): path = os.path.join(folder, entry) if os.path.isdir(path): files.extend(get_data_files_in_folder(path)) elif os.path.isfile(path) and path.endswith('.json'): files.append(path) return files def main(deps: list = [], watch: bool = False, incremental: bool = False) -> None: """ The Aurora runtime. Aurora can be run in two ways: - `aurora build` to build the site once, and; - `aurora serve` to watch for changes in the `pages` directory and rebuild the site in real time. """ global state global all_dependencies data_file_integrity = {} start = datetime.datetime.now() if os.path.exists(DATA_FILES_DIR): for file in get_data_files_in_folder(DATA_FILES_DIR): # remove base /Users/james/src/airport-pianos/pages/ file = file.replace(os.path.abspath(DATA_FILES_DIR) + "/", "") # if dir, recurse file_contents = read_file(os.path.join(DATA_FILES_DIR, file)) if os.path.splitext(file)[-1].replace(".", "") == "json": all_data_files[file] = orjson.loads(file_contents) if isinstance(all_data_files[file], dict): all_data_files[file] = [ {k: v for k, v in all_data_files[file].items()} ] state[file.replace(".json", "")] = all_data_files[file] elif os.path.splitext(file)[-1].replace(".", "") == "csv": all_data_files[file] = list(csv.DictReader(file_contents.split("\n"))) state[file.replace(".csv", "")] = all_data_files[file] else: logging.debug( f"Unsupported data file format: {file}", level=logging.CRITICAL ) print(f"Unsupported data file format: {file}") if not os.path.exists(SITE_DIR): os.makedirs(SITE_DIR) else: if not deps and not incremental: for root, _, files in os.walk(SITE_DIR): for file in files: os.remove(os.path.join(root, file)) for root, _, files in os.walk(ROOT_DIR): for file in files: ext = os.path.splitext(file)[-1].replace(".", "") if ext not in ALLOWED_EXTENSIONS: continue all_pages.append(os.path.join(root, file)) for page in all_pages: if deps and page not in deps and not incremental: continue contents = read_file(page) try: if page.endswith(".md"): all_opened_pages[page] = contents else: all_opened_pages[page] = JINJA2_ENV.from_string(contents) all_page_contents[page] = loads(contents) if SITE_STATE.get("enable_backlinks"): page_links = BeautifulSoup( pyromark.html(contents), "html.parser" ).find_all("a", href=True) # if in posts/, assign permalink if page.startswith("pages/posts/"): # permalink should be YYYY-MM-DD-slug.md turned into /YYYY/MM/DD/slug/ yyyy_mm_dd = re.search(r"\d{4}-\d{2}-\d{2}", page) if yyyy_mm_dd: yyyy_mm_dd = yyyy_mm_dd.group(0) slug = page.split(yyyy_mm_dd)[1].replace(".md", "")[1:] yyyy_mm_dd_slug = f"{yyyy_mm_dd.replace('-', '/')}/{slug}" # all_page_contents[page].metadata[ # "permalink" # ] = f"/{yyyy_mm_dd_slug.strip('/')}/" all_page_contents[page].metadata["outgoing_links"] = page_links except Exception as e: # logging.debug(f"Error reading {page}", level=logging.CRITICAL) # pass raise e if SITE_STATE.get("enable_backlinks"): for page in all_opened_pages: for link in all_page_contents[page].metadata.get("outgoing_links", []): state["backlinks"][link["href"]].append( { "url": all_page_contents[page].metadata.get("permalink"), "title": all_page_contents[page].metadata.get("title", ""), } ) # sort all_opened_pages alpha all_opened_pages_sorted = list(sorted(all_page_contents.items())) # reverse so that we can get next and previous all_opened_pages_sorted.reverse() for i, page in enumerate(all_opened_pages_sorted): # add next and previous page if i < len(all_opened_pages_sorted) - 1: all_page_contents[page[0]].metadata["previous"] = { "url": all_opened_pages_sorted[i + 1][1].metadata.get("permalink", ""), "title": all_opened_pages_sorted[i + 1][1].metadata.get("title", ""), } previous_in_same_category = None # look at all posts before i for j in range(i + 1, len(all_opened_pages_sorted)): # print(f"Comparing {all_opened_pages_sorted[j][1].metadata.get('categories', [])} with {page[1].metadata.get('categories', [])}") if all_opened_pages_sorted[j][1].metadata.get("categories", []) == page[ 1 ].metadata.get("categories"): previous_in_same_category = all_opened_pages_sorted[j][1] break if previous_in_same_category: # print(f"Setting previous in same category for {page[1].metadata.get('title')} as {previous_in_same_category.metadata.get('title')} where next is {next_in_same_category.metadata.get('title') if next_in_same_category else None}") all_page_contents[page[0]].metadata["previous_in_same_category"] = { "url": previous_in_same_category.metadata.get("permalink", ""), "title": previous_in_same_category.metadata.get("title", ""), } if i > 0: all_page_contents[page[0]].metadata["next"] = { "url": all_opened_pages_sorted[i - 1][1].metadata.get("permalink", ""), "title": all_opened_pages_sorted[i - 1][1].metadata.get("title", ""), } next_in_same_category = None for j in range(i - 1, -1, -1): if all_opened_pages_sorted[j][1].metadata.get("categories", []) == page[ 1 ].metadata.get("categories"): next_in_same_category = all_opened_pages_sorted[j][1] break if next_in_same_category: all_page_contents[page[0]].metadata["next_in_same_category"] = { "url": next_in_same_category.metadata.get("permalink", ""), "title": next_in_same_category.metadata.get("title", ""), } if deps: deps = set(deps) new_deps = [] while deps: dep = deps.pop() new_deps.append(dep) if dep in reverse_deps: deps.update(reverse_deps[dep]) deps = new_deps if incremental: data = get_state_from_last_build() if data != {}: data_file_integrity = data.get("data_file_integrity", {}) changed_files = load_data_from_data_files(deps, data_file_integrity) deps.extend(changed_files) deps.extend(calculate_dependencies_from_saved_state(all_dependencies)) if len(deps) == 0: print("No changes detected. Exiting.") return else: load_data_from_data_files(deps, data_file_integrity) else: load_data_from_data_files(deps, data_file_integrity) for page, contents in all_opened_pages.items(): # if incremental, only recompute dependencies for changed files if deps and page not in deps and not incremental: continue dependencies, parsed_page = get_file_dependencies_and_evaluated_contents( page, contents ) all_dependencies[page] = dependencies all_parsed_pages[page] = parsed_page for dependency in dependencies: if dependency not in reverse_deps: reverse_deps[dependency] = set() reverse_deps[dependency].add(page) if page.startswith("posts/"): state["posts"].append(parsed_page) posts = [ key for key in all_opened_pages.keys() if key.startswith(os.path.join(ROOT_DIR, "/posts")) ] for post in posts: if not hasattr(all_parsed_pages[post], "metadata"): continue if all_parsed_pages[post].metadata.get("date"): date = all_parsed_pages[post].metadata["date"] dates.add(date) if date.year not in years: years[date.year] = {} if date.month not in years[date.year]: years[date.year][date.month] = {} if date.day not in years[date.year][date.month]: years[date.year][date.month][date.day] = [] years[date.year][date.month][date.day].append(post) state["years"] = years state["posts"] = sorted( state["posts"], # if post has "hms", sort by slug date then hms; otherwise, sort by slug key=lambda x: ( "-".join(x.metadata["slug"].split("-")[:3]) + "-" + x.metadata.get("hms", "") if x.metadata.get("hms") else x["slug"] ), reverse=True, ) all_dependencies = { k: v for k, v in all_dependencies.items() if not k.startswith("pages/_") } dependencies = ( deps if incremental and len(deps) > 0 else list(toposort_flatten(all_dependencies)) ) dependencies = [ dependency for dependency in dependencies if not dependency.startswith("pages/_") ] if watch: iterator = dependencies else: iterator = tqdm.tqdm(dependencies) iterator_set = set(iterator) print("Generating pages in memory...") for file in iterator: if os.path.isdir(file): for root, _, files in os.walk(file): for file in files: file_path = os.path.join(root, file) if file_path not in iterator_set: render_page(file_path, skip_hooks=watch) else: render_page(file, skip_hooks=watch) print("Saving files to disk...") if not incremental: for root, _, files in os.walk("assets"): for file in files: path = os.path.join(SITE_DIR, root) if not os.path.exists(path): os.makedirs(path) contents = read_file(os.path.join(root, file), "rb") with open(os.path.join(SITE_DIR, root, file), "wb") as f2: f2.write(contents) if incremental and deps: for file in tqdm.tqdm(state_to_write): if original_file_to_permalink.get(file) in deps: with open(file, "wb", buffering=1000) as f: f.write(state_to_write[file].encode()) else: for file in tqdm.tqdm(state_to_write): with open(file, "wb", buffering=1000) as f: f.write(state_to_write[file].encode()) if any(k.startswith("pages/") for k in all_dependencies): if "skip_date_archive_page_generation" not in SITE_STATE: process_date_archives() if "skip_category_page_generation" not in SITE_STATE: process_archives( SITE_STATE.get("category_template", "category"), "categories", SITE_STATE.get("category_slug_root", "category"), ) if "skip_tag_page_generation" not in SITE_STATE: process_archives( SITE_STATE.get("tag_template", "tag"), "tags", SITE_STATE.get("tag_slug_root", "tag"), ) for collection_name, attributes in SITE_STATE.get("paginators", {}).items(): generate_paginated_page_for_collection( collection_name, attributes["per_page"], attributes["template"] ) for hooks in EVALUATED_POST_BUILD_HOOKS.values(): for hook in hooks: hook(state) if incremental: to_save = { "last_build": state["build_timestamp"], "data_file_integrity": data_file_integrity, } json.dump(to_save, open("state.json", "w")) print( f"Built site in \033[94m{(datetime.datetime.now() - start).total_seconds():.3f}s\033[0m ✨\n" ) if watch: from livereload import Server srv = Server() for permalink, files in permalinks.items(): if len(files) > 1: yellow = "\033[93m" print( f"{yellow}Warning: {permalink} has multiple files: {files}{yellow}" ) # logging.disable(logging.INFO) print("Live reload mode enabled.\nWatching for changes...\n") print("View your site at \033[92mhttp://localhost:8000\033[0m") print("Press Ctrl+C to stop.") srv.watch(ROOT_DIR, lambda: main(deps=[srv.watcher.filepath], incremental=True)) srv.watch("./assets", lambda: copy_asset_to_site([srv.watcher.filepath])) srv.serve(root=SITE_DIR, liveport=35729, port=8000, debug=False) else: for permalink, files in permalinks.items(): if len(files) > 1: yellow = "\033[93m" print( f"{yellow}Warning: {permalink} has multiple files: {files}{yellow}" ) ================================================ FILE: aurora/templates/index.html ================================================ Welcome to your website!

Welcome to your new website! 🤗

Below are some helpful links to get you started in making your site with Aurora.

================================================ FILE: docs/assets/prism.css ================================================ /* PrismJS 1.29.0 https://prismjs.com/download.html#themes=prism&languages=markup+bash+python */ code[class*=language-],pre[class*=language-]{color:#000;background:0 0;text-shadow:0 1px #fff;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}code[class*=language-] ::-moz-selection,code[class*=language-]::-moz-selection,pre[class*=language-] ::-moz-selection,pre[class*=language-]::-moz-selection{text-shadow:none;background:#b3d4fc}code[class*=language-] ::selection,code[class*=language-]::selection,pre[class*=language-] ::selection,pre[class*=language-]::selection{text-shadow:none;background:#b3d4fc}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#f5f2f0}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#708090}.token.punctuation{color:#999}.token.namespace{opacity:.7}.token.boolean,.token.constant,.token.deleted,.token.number,.token.property,.token.symbol,.token.tag{color:#905}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#690}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url{color:#9a6e3a;background:hsla(0,0%,100%,.5)}.token.atrule,.token.attr-value,.token.keyword{color:#07a}.token.class-name,.token.function{color:#dd4a68}.token.important,.token.regex,.token.variable{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help} ================================================ FILE: docs/assets/prism.js ================================================ /* PrismJS 1.29.0 https://prismjs.com/download.html#themes=prism&languages=markup+bash+python */ var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(e){var n=/(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i,t=0,r={},a={manual:e.Prism&&e.Prism.manual,disableWorkerMessageHandler:e.Prism&&e.Prism.disableWorkerMessageHandler,util:{encode:function e(n){return n instanceof i?new i(n.type,e(n.content),n.alias):Array.isArray(n)?n.map(e):n.replace(/&/g,"&").replace(/=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&&(jg.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"+i.content+""},!e.document)return e.addEventListener?(a.disableWorkerMessageHandler||e.addEventListener("message",(function(n){var t=JSON.parse(n.data),r=t.language,i=t.code,l=t.immediateClose;e.postMessage(a.highlight(i,a.languages[r],r)),l&&e.close()}),!1),a):a;var g=a.util.currentScript();function f(){a.manual||a.highlightAll()}if(g&&(a.filename=g.src,g.hasAttribute("data-manual")&&(a.manual=!0)),!a.manual){var h=document.readyState;"loading"===h||"interactive"===h&&g&&g.defer?document.addEventListener("DOMContentLoaded",f):window.requestAnimationFrame?window.requestAnimationFrame(f):window.setTimeout(f,16)}return a}(_self);"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism); Prism.languages.markup={comment:{pattern://,greedy:!0},prolog:{pattern:/<\?[\s\S]+?\?>/,greedy:!0},doctype:{pattern:/"'[\]]|"[^"]*"|'[^']*')+(?:\[(?:[^<"'\]]|"[^"]*"|'[^']*'|<(?!!--)|)*\]\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://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(/&/,"&"))})),Object.defineProperty(Prism.languages.markup.tag,"addInlined",{value:function(a,e){var s={};s["language-"+e]={pattern:/(^$)/i,lookbehind:!0,inside:Prism.languages[e]},s.cdata=/^$/i;var t={"included-cdata":{pattern://i,inside:s}};t["language-"+e]={pattern:/[\s\S]+/,inside:Prism.languages[e]};var n={};n[a]={pattern:RegExp("(<__[^>]*>)(?:))*\\]\\]>|(?!)".replace(/__/g,(function(){return a})),"i"),lookbehind:!0,greedy:!0,inside:t},Prism.languages.insertBefore("markup","cdata",n)}}),Object.defineProperty(Prism.languages.markup.tag,"addAttribute",{value:function(a,e){Prism.languages.markup.tag.inside["special-attr"].push({pattern:RegExp("(^|[\"'\\s])(?:"+a+")\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+(?=[\\s>]))","i"),lookbehind:!0,inside:{"attr-name":/^[^\s=]+/,"attr-value":{pattern:/=[\s\S]+/,inside:{value:{pattern:/(^=\s*(["']|(?!["'])))\S[\s\S]*(?=\2$)/,lookbehind:!0,alias:[e,"language-"+e],inside:Prism.languages[e]},punctuation:[{pattern:/^=/,alias:"attr-equals"},/"|'/]}}}})}}),Prism.languages.html=Prism.languages.markup,Prism.languages.mathml=Prism.languages.markup,Prism.languages.svg=Prism.languages.markup,Prism.languages.xml=Prism.languages.extend("markup",{}),Prism.languages.ssml=Prism.languages.xml,Prism.languages.atom=Prism.languages.xml,Prism.languages.rss=Prism.languages.xml; !function(e){var t="\\b(?:BASH|BASHOPTS|BASH_ALIASES|BASH_ARGC|BASH_ARGV|BASH_CMDS|BASH_COMPLETION_COMPAT_DIR|BASH_LINENO|BASH_REMATCH|BASH_SOURCE|BASH_VERSINFO|BASH_VERSION|COLORTERM|COLUMNS|COMP_WORDBREAKS|DBUS_SESSION_BUS_ADDRESS|DEFAULTS_PATH|DESKTOP_SESSION|DIRSTACK|DISPLAY|EUID|GDMSESSION|GDM_LANG|GNOME_KEYRING_CONTROL|GNOME_KEYRING_PID|GPG_AGENT_INFO|GROUPS|HISTCONTROL|HISTFILE|HISTFILESIZE|HISTSIZE|HOME|HOSTNAME|HOSTTYPE|IFS|INSTANCE|JOB|LANG|LANGUAGE|LC_ADDRESS|LC_ALL|LC_IDENTIFICATION|LC_MEASUREMENT|LC_MONETARY|LC_NAME|LC_NUMERIC|LC_PAPER|LC_TELEPHONE|LC_TIME|LESSCLOSE|LESSOPEN|LINES|LOGNAME|LS_COLORS|MACHTYPE|MAILCHECK|MANDATORY_PATH|NO_AT_BRIDGE|OLDPWD|OPTERR|OPTIND|ORBIT_SOCKETDIR|OSTYPE|PAPERSIZE|PATH|PIPESTATUS|PPID|PS1|PS2|PS3|PS4|PWD|RANDOM|REPLY|SECONDS|SELINUX_INIT|SESSION|SESSIONTYPE|SESSION_MANAGER|SHELL|SHELLOPTS|SHLVL|SSH_AUTH_SOCK|TERM|UID|UPSTART_EVENTS|UPSTART_INSTANCE|UPSTART_JOB|UPSTART_SESSION|USER|WINDOWID|XAUTHORITY|XDG_CONFIG_DIRS|XDG_CURRENT_DESKTOP|XDG_DATA_DIRS|XDG_GREETER_DATA_DIR|XDG_MENU_PREFIX|XDG_RUNTIME_DIR|XDG_SEAT|XDG_SEAT_PATH|XDG_SESSION_DESKTOP|XDG_SESSION_ID|XDG_SESSION_PATH|XDG_SESSION_TYPE|XDG_VTNR|XMODIFIERS)\\b",a={pattern:/(^(["']?)\w+\2)[ \t]+\S.*/,lookbehind:!0,alias:"punctuation",inside:null},n={bash:a,environment:{pattern:RegExp("\\$"+t),alias:"constant"},variable:[{pattern:/\$?\(\([\s\S]+?\)\)/,greedy:!0,inside:{variable:[{pattern:/(^\$\(\([\s\S]+)\)\)/,lookbehind:!0},/^\$\(\(/],number:/\b0x[\dA-Fa-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:[Ee]-?\d+)?/,operator:/--|\+\+|\*\*=?|<<=?|>>=?|&&|\|\||[=!+\-*/%<>^&|]=?|[?~:]/,punctuation:/\(\(?|\)\)?|,|;/}},{pattern:/\$\((?:\([^)]+\)|[^()])+\)|`[^`]+`/,greedy:!0,inside:{variable:/^\$\(|^`|\)$|`$/}},{pattern:/\$\{[^}]+\}/,greedy:!0,inside:{operator:/:[-=?+]?|[!\/]|##?|%%?|\^\^?|,,?/,punctuation:/[\[\]]/,environment:{pattern:RegExp("(\\{)"+t),lookbehind:!0,alias:"constant"}}},/\$(?:\w+|[#?*!@$])/],entity:/\\(?:[abceEfnrtv\\"]|O?[0-7]{1,3}|U[0-9a-fA-F]{8}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{1,2})/};e.languages.bash={shebang:{pattern:/^#!\s*\/.*/,alias:"important"},comment:{pattern:/(^|[^"{\\$])#.*/,lookbehind:!0},"function-name":[{pattern:/(\bfunction\s+)[\w-]+(?=(?:\s*\(?:\s*\))?\s*\{)/,lookbehind:!0,alias:"function"},{pattern:/\b[\w-]+(?=\s*\(\s*\)\s*\{)/,alias:"function"}],"for-or-select":{pattern:/(\b(?:for|select)\s+)\w+(?=\s+in\s)/,alias:"variable",lookbehind:!0},"assign-left":{pattern:/(^|[\s;|&]|[<>]\()\w+(?:\.\w+)*(?=\+?=)/,inside:{environment:{pattern:RegExp("(^|[\\s;|&]|[<>]\\()"+t),lookbehind:!0,alias:"constant"}},alias:"variable",lookbehind:!0},parameter:{pattern:/(^|\s)-{1,2}(?:\w+:[+-]?)?\w+(?:\.\w+)*(?=[=\s]|$)/,alias:"variable",lookbehind:!0},string:[{pattern:/((?:^|[^<])<<-?\s*)(\w+)\s[\s\S]*?(?:\r?\n|\r)\2/,lookbehind:!0,greedy:!0,inside:n},{pattern:/((?:^|[^<])<<-?\s*)(["'])(\w+)\2\s[\s\S]*?(?:\r?\n|\r)\3/,lookbehind:!0,greedy:!0,inside:{bash:a}},{pattern:/(^|[^\\](?:\\\\)*)"(?:\\[\s\S]|\$\([^)]+\)|\$(?!\()|`[^`]+`|[^"\\`$])*"/,lookbehind:!0,greedy:!0,inside:n},{pattern:/(^|[^$\\])'[^']*'/,lookbehind:!0,greedy:!0},{pattern:/\$'(?:[^'\\]|\\[\s\S])*'/,greedy:!0,inside:{entity:n.entity}}],environment:{pattern:RegExp("\\$?"+t),alias:"constant"},variable:n.variable,function:{pattern:/(^|[\s;|&]|[<>]\()(?:add|apropos|apt|apt-cache|apt-get|aptitude|aspell|automysqlbackup|awk|basename|bash|bc|bconsole|bg|bzip2|cal|cargo|cat|cfdisk|chgrp|chkconfig|chmod|chown|chroot|cksum|clear|cmp|column|comm|composer|cp|cron|crontab|csplit|curl|cut|date|dc|dd|ddrescue|debootstrap|df|diff|diff3|dig|dir|dircolors|dirname|dirs|dmesg|docker|docker-compose|du|egrep|eject|env|ethtool|expand|expect|expr|fdformat|fdisk|fg|fgrep|file|find|fmt|fold|format|free|fsck|ftp|fuser|gawk|git|gparted|grep|groupadd|groupdel|groupmod|groups|grub-mkconfig|gzip|halt|head|hg|history|host|hostname|htop|iconv|id|ifconfig|ifdown|ifup|import|install|ip|java|jobs|join|kill|killall|less|link|ln|locate|logname|logrotate|look|lpc|lpr|lprint|lprintd|lprintq|lprm|ls|lsof|lynx|make|man|mc|mdadm|mkconfig|mkdir|mke2fs|mkfifo|mkfs|mkisofs|mknod|mkswap|mmv|more|most|mount|mtools|mtr|mutt|mv|nano|nc|netstat|nice|nl|node|nohup|notify-send|npm|nslookup|op|open|parted|passwd|paste|pathchk|ping|pkill|pnpm|podman|podman-compose|popd|pr|printcap|printenv|ps|pushd|pv|quota|quotacheck|quotactl|ram|rar|rcp|reboot|remsync|rename|renice|rev|rm|rmdir|rpm|rsync|scp|screen|sdiff|sed|sendmail|seq|service|sftp|sh|shellcheck|shuf|shutdown|sleep|slocate|sort|split|ssh|stat|strace|su|sudo|sum|suspend|swapon|sync|sysctl|tac|tail|tar|tee|time|timeout|top|touch|tr|traceroute|tsort|tty|umount|uname|unexpand|uniq|units|unrar|unshar|unzip|update-grub|uptime|useradd|userdel|usermod|users|uudecode|uuencode|v|vcpkg|vdir|vi|vim|virsh|vmstat|wait|watch|wc|wget|whereis|which|who|whoami|write|xargs|xdg-open|yarn|yes|zenity|zip|zsh|zypper)(?=$|[)\s;|&])/,lookbehind:!0},keyword:{pattern:/(^|[\s;|&]|[<>]\()(?:case|do|done|elif|else|esac|fi|for|function|if|in|select|then|until|while)(?=$|[)\s;|&])/,lookbehind:!0},builtin:{pattern:/(^|[\s;|&]|[<>]\()(?:\.|:|alias|bind|break|builtin|caller|cd|command|continue|declare|echo|enable|eval|exec|exit|export|getopts|hash|help|let|local|logout|mapfile|printf|pwd|read|readarray|readonly|return|set|shift|shopt|source|test|times|trap|type|typeset|ulimit|umask|unalias|unset)(?=$|[)\s;|&])/,lookbehind:!0,alias:"class-name"},boolean:{pattern:/(^|[\s;|&]|[<>]\()(?:false|true)(?=$|[)\s;|&])/,lookbehind:!0},"file-descriptor":{pattern:/\B&\d\b/,alias:"important"},operator:{pattern:/\d?<>|>\||\+=|=[=~]?|!=?|<<[<-]?|[&\d]?>>|\d[<>]&?|[<>][&=]?|&[>&]?|\|[&|]?/,inside:{"file-descriptor":{pattern:/^\d/,alias:"important"}}},punctuation:/\$?\(\(?|\)\)?|\.\.|[{}[\];\\]/,number:{pattern:/(^|\s)(?:[1-9]\d*|0)(?:[.,]\d+)?\b/,lookbehind:!0}},a.inside=e.languages.bash;for(var s=["comment","function-name","for-or-select","assign-left","parameter","string","environment","function","keyword","builtin","boolean","file-descriptor","operator","punctuation","number"],o=n.variable[1].inside,i=0;i]?|>[=>]?|[&|^~]/,punctuation:/[{}[\];(),.:]/},Prism.languages.python["string-interpolation"].inside.interpolation.inside.rest=Prism.languages.python,Prism.languages.py=Prism.languages.python; ================================================ FILE: docs/config.py ================================================ import os BASE_URLS = { "local": "http://localhost:8000/", "production": "https://jamesg.blog/aurora/", } SITE_ENV = os.environ.get("SITE_ENV", "local") BASE_URL = BASE_URLS[SITE_ENV] ROOT_DIR = "pages" LAYOUTS_BASE_DIR = "_layouts" SITE_DIR = "_site" HOOKS = { "post_template_generation": {"highlighting": ["highlight_code"]}, "pre_template_generation": {"highlighting": ["generate_table_of_contents"]}, } SITE_STATE = {} ================================================ FILE: docs/highlighting.py ================================================ from bs4 import BeautifulSoup from pygments import highlight from pygments.formatters import HtmlFormatter from pygments.lexers import CssLexer, HtmlLexer, PythonLexer languages = { "python": PythonLexer(), "html": HtmlLexer(), "text": HtmlLexer(), "css": CssLexer(), } def highlight_code(file_name, page_state, _, page_contents): if ".txt" in file_name or ".xml" in file_name or "styles.html" in file_name: return page_contents soup = BeautifulSoup(page_contents, "lxml") for pre in soup.find_all("pre"): code = pre.find("code") try: language = code["class"][0].split("language-")[1] code = highlight(code.text, languages[language], HtmlFormatter()) except: continue pre.replace_with(BeautifulSoup(code, "html.parser")) if soup.find("article", {"class": "post"}): # add id to all h2s, h3s, etc. for h2 in soup.find_all(["h2", "h3", "h4", "h5", "h6"]): h2["id"] = h2.text.lower().replace(" ", "-") # surround h2 with a link for h2 in soup.find_all(["h2", "h3", "h4", "h5", "h6"]): link = soup.new_tag("a", href=f"#{h2['id']}") link.string = h2.text # Set the link text to the h2's current text h2.clear() h2.append(link) # for each "sup", add class=f-1 for i, sup in enumerate(soup.find_all("sup")): sup["id"] = f"f-{i+1}" # get all footnote-definition and add [↩] link to end for footnote in soup.find_all("div", {"class": "footnote-definition"}): link = soup.new_tag("a", href=f"#f-{footnote['id']}") link.string = "[↩]" footnote.append(link) css = HtmlFormatter().get_style_defs(".highlight") css = f"" # this happens for bookmarks if not soup.find("body"): return "" body = soup.find("body") body.insert(0, BeautifulSoup(css, "html.parser")) return str(soup) def generate_table_of_contents(file_name, page_state, site_state): page = BeautifulSoup(page_state["page"].contents, "html.parser") h2s = page.find_all("h2") toc = [] for h2 in h2s: toc.append( {"text": h2.text, "id": h2.text.lower().replace(" ", "-"), "children": []} ) h3s = h2.find_next_siblings("h3") for h3 in h3s: # if h3 is a child of another h3, skip it if h3.find_previous_sibling("h2") != h2: continue toc[-1]["children"].append( { "text": h3.text, "id": h3.text.lower().replace(" ", "-"), } ) page_state["page"].toc = toc return page_state ================================================ FILE: docs/pages/_layouts/default.html ================================================ {{ page.title }} | Aurora User Manual
{{ content }}
{% if not site.page.notoc %} {% endif %}
================================================ FILE: docs/pages/templates/404.html ================================================ --- title: 404 permalink: /404.html layout: default ---

This page does not exist. Go back to the homepage.

================================================ FILE: docs/pages/templates/archives.md ================================================ --- title: Date, Category, and Tag Archives layout: default permalink: /archives/ --- Aurora has support built-in for generating date, category, and tag archives. These are useful for blogs. ## Date Archives Aurora automatically generates date archives for blog posts. You do not need to configure any setting to use this feature. Date archives are generated as follows: - `https://example.com/2024/`: All posts published in 2024. - `https://example.com/2024/01/`: All posts published in January 2024. - `https://example.com/2024/01/01/`: All posts published on January 1, 2024. ## Category and Tag Archives Aurora automatically generates category and tag archives. These archives are generated if you specify `category` and/or `tag` attributes in your blog post front matters. Category archives are generated as follows: - `https://example.com/category/`: All posts with the specified category. Tag archives are generated as follows: - `https://example.com/tag/`: All posts with the specified tag. ### Customize Category and Tag Paths You can change the default category and tag path roots. To do so, update the `SITE_STATE` value in your config.py configuration to include:
SITE_STATE = {
    "category_slug_root": "categories",
    "tag_slug_root": "tags",
}
The above example would change the category and tag paths to: - `https://example.com/categories/`: All posts with the specified category. - `https://example.com/tags/`: All posts with the specified tag. ================================================ FILE: docs/pages/templates/blog.html ================================================ --- title: Use Aurora as a Blog permalink: /blog/ layout: default ---

Aurora has out-of-the-box features designed for use with blogs.

Write a Blog Post

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

YYYY-MM-DD-title.md

For example, 2020-01-01-hello-world.md.

Within this file, you can specify front matter and, optionally, jinja2 templating.

Here is an example:

---
title: Hello, World!
layout: post
---

This is our first blog post.

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

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

Categories and Tags

Aurora can automatically generate archive pages for categories and tags.

Categories and tags are treated as two separate collections.

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

---
title: Hello, World!
layout: post
categories:
  - Announcement
---

This is our first blog post.

Categories and tags are not case-sensitive.

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

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

This file will have access to a page.posts variable that lists all posts in the category or tag.

Here is an example of a category layout file:

{% raw %}

---
layout: default
title: "Category Archive"
---

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

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

Date Archives

Aurora can automatically generate date pages for categories and tags.

Date archives show all posts published on a specific day.

There is not currently support for month-based or year-based archives.

To use this feature, you need to have at least one blog post.

You then need to specify a date archive layout.

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

This file will have access to a page.posts variable that lists all posts published on a specific day.

Here is an example of a category layout file:

{% raw %}

---
layout: default
title: "Date Archive"
---

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

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

Now you have a blog set up with Aurora! 🎉

================================================ FILE: docs/pages/templates/build-methods.md ================================================ --- title: Build Methods permalink: /build-methods/ layout: default --- There are three ways you can build your Aurora site: 1. Full build 2. Incremental build 3. Interactive, incremental build ## Full Build A full build generates your entire website. Your site is saved in `_site`, ready for serving. To build your site, navigate to the root directory of your project (the folder with the `config.py` file in it), and run:
aurora build
Your site will be saved in and ready to serve from the `_site` directory. ## Incremental Build An incremental build generates only the files that have changed since the last build. This is faster than a full build. If you have not fully built your site before, the incremental build will fully build your site first. Then, subsequent runs will only build the files that have changed since the last build. For example, suppose you have 1,000 pages on your site. You have already built your site, and now you change one file. With the incremental build, option, only the page you changed -- and its dependencies -- will be regenerated. Incremental builds are designed to speed up the build process, particularly for large sites with thousands or tens of thousands of pages. To run an incremental build, navigate to the root directory of your project and run:
aurora build --incremental

Tip: Incremental builds support CSV and JSON data files.

## Interactive, Incremental Build An interactive, incremental build generates your full site. It starts a web server through which you can preview pages. When you make a change to any file, the changed file -- and its dependencies -- are re-built and made available over the server. Any open browser tabs that are viewing the site will automatically refresh to show the changes. This mode is intended for development. With interactive, incremental building, you can see changes to your site as you make them, without having to wait for your full site to build, and without having to manually refresh your browser. To run an interactive, incremental build, navigate to the root directory of your project and run:
aurora serve
A server will start on `http://localhost:8000`. Open this URL in your browser to view your site.

Note: The interactive server should not be used in production.

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

You can turn data from JSON and CSV files into web pages.

This is useful if you have a data set that you want to turn into a website.

For example, you could export a list of coffee shops you have visited from a spreadsheet and turn the list into a static website.

Create a Collection

JSON

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

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

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

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

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

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

CSV

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

Here is an example CSV file:

slug,layout,title
rosslyn-coffee,coffee,Rosslyn Coffee in London is terrific.

Your CSV file must have a header row that contains the keys for each entry.

This file is called pages/_data/coffee.csv.

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

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

================================================ FILE: docs/pages/templates/collections.md ================================================ --- title: Data Collections permalink: /collections/ layout: default --- Data collections are groups of data on a website. You can use collections to create lists of content items (i.e. all of the bookmarks on your website). You can create a data collection by: 1. Loading data from a JSON file 2. Loading data from a CSV file 3. Specifying a `collections` value on any page on your website

Create a Collection

JSON

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

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

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

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

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

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

CSV

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

Here is an example CSV file:

slug,layout,title
rosslyn-coffee,coffee,Rosslyn Coffee in London is terrific.

Your CSV file must have a header row that contains the keys for each entry.

This file is called pages/_data/coffee.csv.

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

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

# Specify a Collections Attribute If you want to group multiple existing files together, you can specify a `collections` attribute on any page on your website. To do so, use the following syntax:
---
title: My Page
collections: coffee
---
You can then access the collection like so:
{% raw %}{% for item in coffee %}{% endraw %}
    {{ item.title }}
{% raw %}{% endfor %}{% endraw %}
================================================ FILE: docs/pages/templates/configuration.html ================================================ --- title: Configure Your Website layout: default ---

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

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

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

import os

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

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

Base URLs

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

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

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

See Also

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

Aurora has several default filters that you can use to handle dates.

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

These filters can be used like:

{% raw %}{{ date | long_date }}
{{ date | date_to_xml_string }}
{{ date | archive_date }}
{{ date | month_number_to_written_month }}{% endraw %}
================================================ FILE: docs/pages/templates/design.html ================================================ --- title: Aurora Design permalink: /design/ layout: default ---

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

See a list of these posts below.

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

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

These functions are called "hooks".

There are three types of hooks, which run:

  1. As a jinja2 filter you can access on all pages (template_filters hook)
  2. Immediately before a page is generated (pre_generation hook)
  3. After your site has built (post_build hook)

To define a hook, you need to:

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

Below are instructions on how to define each type of hook.

Filter Hooks

Filter hooks are registered as a jinja2 filter.

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

The type signature of this hook is:


def hook_name(text: str) -> str:
    return text.upper()

You can register this hook in the template_filter hook:


HOOKS = {
    "template_filter": {
        "example": ["hook_name"]
    }
}

This hook can then be used in any template on your website:


<h1> "hello world" | hook_name </h1>

Pre-Generation Hooks

Pre-generation hooks run immediately before a page is generated.

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.)

The type signature of this hook is:


def hook_name(file_name: str, page_state: dict, site_state: dict) -> dict:
    return page_state

You can register this hook in the template_filter hook:


HOOKS = {
    "pre_generation": {
        "example": ["hook_name"]
    }
}

Post-Build Hooks

Post-build hooks run after your site has been built.

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).

The type signature of this hook is:


def hook_name(site_state: str) -> None:
    pass

You can register this hook in the template_filter hook:


HOOKS = {
    "post_build": {
        "example": ["hook_name"]
    }
}
================================================ FILE: docs/pages/templates/index.html ================================================ --- title: Aurora layout: default ---

Aurora is a static site generator implemented in Python.

With Aurora, you can generate thousands of static web pages in seconds.

Aurora supports:

  • Static generation, with support for jinja2 logic
  • Incremental Static Regeneration (ISR) with hot reloading
  • Markdown and HTML content
  • Generating pages from CSV and JSON files

Aurora is open source, and licensed under an MIT license.

Build your first website with Aurora.

View source code.

Demo

================================================ FILE: docs/pages/templates/pagination.md ================================================ --- title: Pagination layout: default permalink: /pagination/ --- You can generate pagination pages for collections. This is ideal if you have a collection with many items that you want to split into multiple pages for ease of navigation. ## Usage To set up pagination, first [create a collection](/collections/). Then, create a new layout in your `_layouts` directory. This layout will be used to generate the pagination pages. This page can access the `page.___` variable, where `___` is the name of the collection (i.e. `page.books` would reference the `page.books` collection). This variable is an array of all the items in a given page in the collection. To see the current page number, reference the `page.page_number` variable. Finally, add a `paginators` key to the `SITE_STATE` value in your `config.py` file:
SITE_STATE = {
    "paginators": {
        "books": {
            "per_page": 10,
            "template": "books"
        }
    }
}
================================================ FILE: docs/pages/templates/performance.html ================================================ --- title: Performance layout: default ---

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

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

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

In a test comparing 11ty to Aurora in generating the Airport Pianos website (~45 pages), 11ty took 1.36 seconds to start and generate the site, whereas Aurora took 0.034 seconds.

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

You can define custom permalinks for a page in its front matter.

To do so, specify the permalink key:

---
title: Book List
permalink: /books/
layout: default
---

The page above will be generated with the path /books/.

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

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

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

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

User-agent: *
Allow: /

Sitemap: /sitemap.xml

See Also

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

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

{% raw %}<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
    {% for page in site.pages %}
        {% if not page.noindex %}
            <url>
                <loc>
                    {{ page.url }}
                </loc>
                <lastmod>{{ site.build_time }}</lastmod>
            </url>
        {% endif %}
    {% endfor %}
</urlset>{% endraw %}
================================================ FILE: docs/pages/templates/start.html ================================================ --- title: Start a Website permalink: /start/ layout: default ---

Learn how to create a website with Aurora.

Install Aurora

First, install Aurora:

pip3 install aurora-ssg

Create a Site

To create a new site, run the following command:

aurora new my-site
cd my-site

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

There are two ways to run Aurora:

  • aurora build: This command builds your site into a folder called _site, which you can view on your file system.
  • aurora serve: This command builds your site and starts a local server at http://localhost:8000 on which your site will run. This is ideal for development.

To see your site locally, run:

aurora serve

This will start a local server at http://localhost:8000 on which your site will run.

A web page

When you are ready to publish your site, you can run:

aurora build

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

You have started an Aurora website 🎉.

Next up: .

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

There are three types of state in Aurora: page, post and site.

Page State

Page state stores values that are only available on that page.

For example, consider the following template:

---
title: Hello, World!
layout: default
---

Welcome to the website!

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

{% raw %}{{ page.title }}{% endraw %}

Tip

You can access the name of the template from which a page was generated with:

{% raw %}{{ page.generated_from }}{% endraw %}

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.

Post State

Post state stores information about a blog post.

You can access post state on any template that is used by a post.

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

---
layout: default
---

<h1>{% raw %}{{ post.title }}{% endraw %}</h1>

<p>{% raw %}{{ post.content }}{% endraw %}</p>

Here, we access the title and content of the post using the post state.

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

---
layout: post
title: Hello, World!
---

...

Site State

Page state stores values that are global to the website.

You can access site state on any page.

By default, site state contains:

  • A list of your posts (site.posts)
  • The root URL of your site (site.root_url)
  • The build date of your site (site.build_date)
  • A list of all pages in your site (site.pages)

For example, consider the following template:

---
title: Blog Home
layout: default
---

{% raw %}
<ul>
    {% for post in site.posts[:5] %}
        <li>
            <a href="{{ post.url }}">{{ post.title }}</a>
        </li>
    {% endfor %}
</ul>
{% endraw %}

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

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

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

SITE_STATE = {
    'site_version': os.getenv('SITE_VERSION', '1.0.0')
}
================================================ FILE: docs/pages/templates/structure.html ================================================ --- title: Website Structure permalink: /structure/ layout: default ---

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

Below is a list of those files.


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

See Also

================================================ FILE: docs/pages/templates/templates.md ================================================ --- title: Templates permalink: /templates/ layout: default --- Below are templates you can use to get started with Aurora.
  • Blog
  • Documentation
  • ================================================ FILE: docs/pages/templates/templating.md ================================================ --- title: Templating with Jinja2 layout: default permalink: /templating/ --- Aurora supports using [jinja2](https://jinja.palletsprojects.com/en/3.1.x/) to create template logic. jinja2 is a popular Python templating engine with support for variable interpolation, conditionals, loops, and more. You can use jinja2 in any HTML or markdown document in your Aurora project. Here is an example of a Jinja2 template that defines a blog home page: ```html --- title: Blog layout: default permalink: /blog/ ---

    Blog

    {% for post in site.posts %}

    {{ post.title }}

    {{ post.content }}

    {% endfor %} ``` ================================================ FILE: docs/pages/templates/users.html ================================================ --- title: Users layout: default ---

    The following sites are built with Aurora:

    If you would like your site to be featured here, please submit a pull request to the Aurora GitHub repository.

    ================================================ FILE: docs/state.json ================================================ {"last_build": "2024-06-22T19:51:46.751675", "data_file_integrity": {"pages/test/apple/index.html": "542b47f9fbb9dca05694183f48c8fe925e193b45", "pages/test/banana/index.html": "f49e2e069c37a6fc12809ba2db409db23cccfba9", "apple": "542b47f9fbb9dca05694183f48c8fe925e193b45", "banana": "f49e2e069c37a6fc12809ba2db409db23cccfba9"}} ================================================ FILE: requirements.txt ================================================ jinja2 livereload toposort pyromark~=0.9.3 python-frontmatter requests progress click orjson tqdm chardet bs4 ================================================ FILE: setup.py ================================================ import re import setuptools from setuptools import find_packages with open("./aurora/__init__.py", "r") as f: content = f.read() # from https://www.py4u.net/discuss/139845 version = re.search(r'__version__\s*=\s*[\'"]([^\'"]*)[\'"]', content).group(1) with open("README.md", "r", encoding="UTF-8") as fh: long_description = fh.read() setuptools.setup( name="aurora-ssg", version=version, author="capjamesg", author_email="readers@jamesg.blog", description="A fast static site generator implemented in Python.", long_description=long_description, long_description_content_type="text/markdown", url="https://github.com/capjamesg/aurora", install_requires=[ "jinja2", "livereload", "toposort", "pyromark>=0.6,<0.10", "python-frontmatter", "requests", "progress", "click", "orjson", "tqdm", "python-dateutil", "chardet", "bs4" ], include_package_data=True, package_data={"": ["templates/index.html"]}, packages=find_packages(exclude=("tests",)), entry_points={ "console_scripts": [ "aurora = aurora.cli:main", ], }, extras_require={ "dev": [ "flake8", "black==25.11.0", "isort", "twine", "pytest", "wheel", "mkdocs-material", "mkdocs", ], }, classifiers=[ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ], python_requires=">=3.7", ) ================================================ FILE: tests/fixtures/about.html ================================================ About - Library

    We serve 100 readers every month.

    ================================================ FILE: tests/fixtures/about_ISO-8859-1.html ================================================ About ISO Test - Library

    A test of a file encoded with ISO-8859-1.

    We serve 100 readers every month.

    ================================================ FILE: tests/fixtures/about_UTF-16-BE.html ================================================ About UTF-16 BE Test - Library

    A test of a file encoded with UTF-16 BE.

    We serve 100 readers every month.

    ================================================ FILE: tests/fixtures/about_Windows-1252.html ================================================ About Windows-1252 Test - Library

    A test of a file encoded with Windows-1252 .

    We serve 100 readers every month.

    ================================================ FILE: tests/fixtures/book.html ================================================ The Great Gatsby - Library

    The Great Gatsby

    F. Scott Fitzgerald

    ================================================ FILE: tests/fixtures/book_list.html ================================================ Book List - Library

    Books

    ================================================ FILE: tests/fixtures/category_archive.html ================================================ Category Archive - Library ================================================ FILE: tests/fixtures/collection_pagination.html ================================================ Rooms - Library
    • Study Hall
    • Quiet Corner
    ================================================ FILE: tests/fixtures/date_year.html ================================================ Date Archive - Library

    Posts from 2024

    Below are the posts we wrote in 2024.

    ================================================ FILE: tests/fixtures/date_year_month.html ================================================ Date Archive - Library

    Posts from 2024/01

    Below are the posts we wrote in 2024/01.

    ================================================ FILE: tests/fixtures/date_year_month_day.html ================================================ Date Archive - Library

    Posts from January 01, 2024

    Below are the posts we wrote on January 01, 2024.

    ================================================ FILE: tests/fixtures/index.html ================================================ Library

    Welcome to the library!

    Browse the books.

    ================================================ FILE: tests/fixtures/new_site_config.py ================================================ import os BASE_URLS = { "local": os.getcwd(), "production": "https://example.com", } SITE_ENV = os.environ.get("SITE_ENV", "local") BASE_URL = BASE_URLS[SITE_ENV] ROOT_DIR = "pages" LAYOUTS_BASE_DIR = "_layouts" SITE_DIR = "_site" HOOKS = {} SITE_STATE = {} ================================================ FILE: tests/fixtures/post.html ================================================ Hello, World! - Library Blog

    This is our first blog post.

    ================================================ FILE: tests/fixtures/review.html ================================================ Review by James - Library

    James

    An excellent book.

    5/5 stars

    ================================================ FILE: tests/fixtures/robots.txt ================================================ User-Agent: * Allow: / ================================================ FILE: tests/fixtures/styles.css ================================================ * { font-family: San system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; } ================================================ FILE: tests/fixtures/tag_archive.html ================================================ Tag Archive - Library ================================================ FILE: tests/library/assets/meta/robots.txt ================================================ User-Agent: * Allow: / ================================================ FILE: tests/library/assets/styles.css ================================================ * { font-family: San system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; } ================================================ FILE: tests/library/config.py ================================================ import os BASE_URLS = { "local": os.getcwd(), "production": "https://example.com", } SITE_ENV = os.environ.get("SITE_ENV", "local") BASE_URL = BASE_URLS[SITE_ENV] ROOT_DIR = "pages" LAYOUTS_BASE_DIR = "_layouts" SITE_DIR = "_site" HOOKS = { "pre_template_generation": {"hooks": ["retrieve_visitor_count"]}, "post_build": {"hooks": ["add_made_by_file"]}, "template_filters": {"hooks": ["capitalize"]}, } SITE_STATE = { "category_slug_root": "category", "tag_slug_root": "tag", "paginators": {"rooms": {"per_page": 50, "template": "rooms"}}, } ================================================ FILE: tests/library/hooks.py ================================================ def retrieve_visitor_count(file_name, page_state, _): page_state["visitors"] = 100 return page_state def add_made_by_file(state): with open("_site/made-by.txt", "w") as f: f.write("Made by the library team.") return state def capitalize(text): return text.upper() ================================================ FILE: tests/library/pages/_data/books.json ================================================ [ {"title": "The Great Gatsby", "author": "F. Scott Fitzgerald", "layout": "book-template", "slug": "the-great-gatsby"} ] ================================================ FILE: tests/library/pages/_data/reviews.csv ================================================ name,review,star,layout James,An excellent book., 5,reader-review ================================================ FILE: tests/library/pages/_layouts/book-template.html ================================================ --- layout: default title: "{{ page.title }}" ---

    {{ page.title }}

    {{ page.author }}

    ================================================ FILE: tests/library/pages/_layouts/category.html ================================================ --- layout: default title: "Category Archive" --- ================================================ FILE: tests/library/pages/_layouts/date.html ================================================ --- layout: default title: "Date Archive" --- {% if page.date_type == "year" %}

    Posts from {{ page.date | year }}

    Below are the posts we wrote in {{ page.date | year }}.

    {% endif %} {% if page.date_type == "month" %}

    Posts from {{ page.date | archive_date }}

    Below are the posts we wrote in {{ page.date | archive_date }}.

    {% endif %} {% if page.date_type == "day" %}

    Posts from {{ page.date | long_date }}

    Below are the posts we wrote on {{ page.date | long_date }}.

    {% endif %} ================================================ FILE: tests/library/pages/_layouts/default.html ================================================ {{ page.title }} - Library {{ content }} ================================================ FILE: tests/library/pages/_layouts/post.html ================================================ {{ post.title }} - Library Blog {{ content }} ================================================ FILE: tests/library/pages/_layouts/reader-review.html ================================================ --- layout: default title: "Review by {{ page.name }}" ---

    {{ page.name }}

    {{ page.review }}

    {{ page.star }}/5 stars

    ================================================ FILE: tests/library/pages/_layouts/rooms.html ================================================ --- layout: default title: Rooms ---
      {% for room in page.current_page %}
    • {{ room.title }}
    • {% endfor %}
    ================================================ FILE: tests/library/pages/_layouts/tag.html ================================================ --- layout: default title: "Tag Archive" --- ================================================ FILE: tests/library/pages/posts/2024-01-01-first-post.md ================================================ --- title: "Hello, World!" layout: post categories: - Featured tags: - Announcements --- This is our first blog post. ================================================ FILE: tests/library/pages/rooms/quiet-corner.html ================================================ --- title: Quiet Corner layout: default collection: rooms ---

    Quiet Corner

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

    Study Hall

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

    We serve {{ page.visitors }} readers every month.

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

    A test of a file encoded with ISO-8859-1.

    We serve {{ page.visitors }} readers every month.

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

    A test of a file encoded with Windows-1252 .

    We serve {{ page.visitors }} readers every month.

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

    Books

    ================================================ FILE: tests/library/pages/templates/index.html ================================================ Library

    Welcome to the library!

    Browse the books.

    ================================================ FILE: tests/library/state.json ================================================ {"last_build": "2024-08-08T10:55:05.167577", "data_file_integrity": {"0": "83cab347b81062bf833f9de2a5f117ee63b02f99", "the-great-gatsby": "99a8216ddb24aee98b88bf4305c53882aee58d4c"}} ================================================ FILE: tests/state.py ================================================ import os import shutil TEST_FOLDER = os.path.join(os.getcwd(), "tests/library") BASE_SITE_DIRECTORY = os.path.join(TEST_FOLDER, "_site") FIXTURES_DIRECTORY = os.path.join(os.getcwd(), "tests/fixtures") os.chdir(TEST_FOLDER) fixtures = {} for file in os.listdir(FIXTURES_DIRECTORY): with open(os.path.join(FIXTURES_DIRECTORY, file)) as f: fixtures[file] = f.read() from aurora.graph import main as build_site def test_build_site(): build_site() assert os.path.exists("_site") def test_config_file_presence(): assert os.path.exists("config.py") def test_rendered_page_from_data_file(): with open( os.path.join(BASE_SITE_DIRECTORY, "books/the-great-gatsby/index.html") ) as f: data = f.read() assert data.strip() == fixtures["book.html"].strip() def test_rendered_page_from_data_file_without_slug(): with open(os.path.join(BASE_SITE_DIRECTORY, "reviews/0/index.html")) as f: data = f.read() assert data.strip() == fixtures["review.html"].strip() def test_rendered_page_from_template(): # also tests title interpolation with open(os.path.join(BASE_SITE_DIRECTORY, "index.html")) as f: data = f.read() assert data.strip() == fixtures["index.html"].strip() def test_permalink_front_matter(): assert os.path.exists(os.path.join(BASE_SITE_DIRECTORY, "book-list/index.html")) def test_rendered_page_with_logic(): # this also tests: # - inheritance working (inheriting from `default`) # - title interpolation working with open(os.path.join(BASE_SITE_DIRECTORY, "book-list/index.html")) as f: data = f.read() assert data.replace(" ", "").replace("\n", "") == fixtures[ "book_list.html" ].replace(" ", "").replace("\n", "") def test_asset_copying(): with open(os.path.join(BASE_SITE_DIRECTORY, "assets/styles.css")) as f: data = f.read() assert data.strip() == fixtures["styles.css"].strip() def test_asset_copying_in_folders(): with open(os.path.join(BASE_SITE_DIRECTORY, "assets/meta/robots.txt")) as f: data = f.read() assert data.strip() == fixtures["robots.txt"].strip() def test_generate_blog_post(): with open( os.path.join(BASE_SITE_DIRECTORY, "2024/01/01/first-post/index.html") ) as f: data = f.read() assert data.strip() == fixtures["post.html"].strip() def test_new_site_generation(): os.system("aurora new test-site") assert os.path.exists("test-site") assert os.path.exists("test-site/assets") assert os.path.exists("test-site/pages") assert os.path.exists("test-site/pages/_layouts") assert os.path.exists("test-site/pages/_data") assert os.path.exists("test-site/pages/posts") assert os.path.exists("test-site/pages/templates/index.html") with open("test-site/config.py") as f: data = f.read() assert data.strip() == fixtures["new_site_config.py"].strip() shutil.rmtree("test-site") def test_pre_generation_hook(): # this page uses {{ page.visitors }}, which is computed in a pre-generation hook with open(os.path.join(BASE_SITE_DIRECTORY, "about/index.html")) as f: data = f.read() assert data.strip() == fixtures["about.html"].strip() def test_post_build_hook(): # check for presence of site/made-by.txt assert os.path.exists(os.path.join(BASE_SITE_DIRECTORY, "made-by.txt")) def test_year_date_archive_generation(): with open(os.path.join(BASE_SITE_DIRECTORY, "2024/index.html")) as f: data = f.read() assert data.strip().replace(" ", "").replace("\n", "") == fixtures[ "date_year.html" ].strip().replace(" ", "").replace("\n", "") def test_year_month_date_archive_generation(): with open(os.path.join(BASE_SITE_DIRECTORY, "2024/01/index.html")) as f: data = f.read() assert data.strip().replace(" ", "").replace("\n", "") == fixtures[ "date_year_month.html" ].strip().replace(" ", "").replace("\n", "") def test_year_month_day_date_archive_generation(): with open(os.path.join(BASE_SITE_DIRECTORY, "2024/01/01/index.html")) as f: data = f.read() assert data.strip().replace(" ", "").replace("\n", "") == fixtures[ "date_year_month_day.html" ].strip().replace(" ", "").replace("\n", "") def test_tag_archive_generation(): with open(os.path.join(BASE_SITE_DIRECTORY, "tag/announcements/index.html")) as f: data = f.read() assert data.strip().replace(" ", "").replace("\n", "") == fixtures[ "tag_archive.html" ].strip().replace(" ", "").replace("\n", "") def test_collection_pagination(): with open(os.path.join(BASE_SITE_DIRECTORY, "rooms/index.html")) as f: data = f.read() assert data.strip().replace(" ", "").replace("\n", "") == fixtures[ "collection_pagination.html" ].strip().replace(" ", "").replace("\n", "") def check_for_presence_of_state_file_after_build(): assert os.path.exists("state.json") def test_incremental_regeneration(): generated_files = [] for root, _, files in os.walk("_site"): for file in files: generated_files.append(os.path.relpath(os.path.join(root, file), "_site")) os.system("aurora build --incremental") new_generated_files = [] for root, _, files in os.walk("_site"): for file in files: new_generated_files.append( os.path.relpath(os.path.join(root, file), "_site") ) assert set(generated_files) == set(new_generated_files)