__init__.py
Python script, ASCII text executable
1#!/usr/bin/env python3 2 3import os 4import re 5import shutil 6import contextlib 7import typing 8import jinja2 9import colorama 10from datetime import datetime 11from ruamel.yaml import YAML 12from ampoule_ssg._utils import * 13 14 15colorama.init() 16 17 18class Document: 19"""A type representing a document, which can be text or binary.""" 20def __init__(self, file_name: typing.Union[str, bytes, os.PathLike], url_transform: typing.Callable = lambda x: x, front_matter_enabled: bool = True): 21"""Create a new document object. 22 23:param file_name: The name of the file to read. 24:param url_transform: Function to change the file name to a different form. 25""" 26self.file_name = file_name 27self.encoding = "utf-8" 28# If the file is text, read it. 29self.front_matter = YAML() 30self.front_matter.Constructor.add_constructor("tag:yaml.org,2002:timestamp", _no_date_constructor) 31self.content = "" 32self.date = datetime.fromtimestamp(os.path.getmtime(file_name)) 33try: 34with open(file_name, "r", encoding=self.encoding) as f: 35print(colorama.Style.RESET_ALL, colorama.Style.BRIGHT, colorama.Fore.LIGHTWHITE_EX, f"Loading document {file_name}".ljust(shutil.get_terminal_size().columns), sep="") 36 37# Parse front matter if available. 38front_matter = "" 39if front_matter_enabled: 40initial_line = f.readline() 41if initial_line == "---\n": 42print(colorama.Style.RESET_ALL, colorama.Fore.CYAN, "Front matter found", sep="") 43line = "" 44while line != "---\n": 45line = f.readline() 46if line != "---\n": 47front_matter += line 48print(colorama.Style.RESET_ALL, colorama.Fore.GREEN, "Front matter loaded", sep="") 49 50if front_matter and front_matter_enabled: 51self.front_matter = self.front_matter.load(front_matter) 52 53print(self.front_matter, type(self.front_matter)) 54 55if "DATE" in self.front_matter: 56self.date = parse_date_string(self.front_matter["DATE"]) 57elif front_matter_enabled: # put it back 58self.content = initial_line 59 60print(colorama.Style.RESET_ALL, colorama.Fore.CYAN, "Reading content", sep="") 61 62self.content += f.read() 63 64print(colorama.Style.RESET_ALL, colorama.Fore.GREEN, "Content loaded", sep="") 65print(colorama.Style.RESET_ALL, colorama.Style.DIM, self.content[:128] + "..." if len(self.content) > 128 else self.content) 66except UnicodeDecodeError: 67print(colorama.Style.RESET_ALL, colorama.Fore.CYAN, "Text decoding failed, assuming binary", sep="") 68self.encoding = None 69with open(file_name, "rb") as f: 70self.content = f.read() 71print(colorama.Style.RESET_ALL, colorama.Fore.GREEN, "Binary content loaded", sep="") 72 73print(colorama.Style.RESET_ALL, colorama.Fore.CYAN, colorama.Style.DIM, f"Transforming URL {self.file_name} ->", end=" ", sep="") 74self.file_name = url_transform(self.file_name) 75print(colorama.Style.RESET_ALL, colorama.Style.BRIGHT, colorama.Fore.LIGHTYELLOW_EX, self.file_name) 76 77print(colorama.Style.RESET_ALL, end="") 78 79def __repr__(self): 80return f"Document({self.file_name})" 81 82def __str__(self): 83return self.content 84 85def __getitem__(self, item: str): 86"""Get an item from the front matter of the document""" 87return self.front_matter[item] 88 89def __setitem__(self, item: str, value: typing.Any): 90"""Set an item in the front matter of the document""" 91self.front_matter[item] = value 92 93def __delitem__(self, item: str): 94"""Delete an item from the front matter of the document""" 95del self.front_matter[item] 96 97def __contains__(self, item: str): 98"""Check if an item is in the front matter of the document""" 99return item in self.front_matter 100 101 102class Index: 103"""A type representing an index of documents.""" 104def __init__(self, directory: typing.Union[str, bytes, os.PathLike], recursive: bool = False, 105url_transform: typing.Callable = lambda x: x, sort_by: typing.Callable = lambda x: x.file_name, reverse: bool = False, 106exclude: typing.Union[str, None] = None, static: bool = False): 107"""Create a new index object. 108 109:param directory: The directory to read the files from. 110:param recursive: Whether to read files from subdirectories. 111:param url_transform: Function to change the file name to a different form. 112:param sort_by: Function returning a key to sort the documents by. 113:param exclude: Regular expression to exclude files from the index. 114""" 115self.directory = directory 116self.static = static 117# Temporarily move to the specified directory in order to read the files. 118if exclude: 119regex = re.compile(exclude) 120else: 121regex = re.compile("(?!)") 122with _in_directory(directory): 123if recursive: 124self.file_names = [os.path.join(dir_path, f) for dir_path, dir_name, filenames in os.walk(".") for f in filenames if not regex.search(f)] 125else: 126self.file_names = [i for i in os.listdir() if os.path.isfile(i) and not regex.search(i)] 127 128self.documents = sorted([Document(i, url_transform, front_matter_enabled=not static) for i in self.file_names], key=sort_by, reverse=reverse) 129self.__current_index = 0 130 131def __iter__(self): 132self.__current_index = 0 133return self 134 135def __next__(self): 136if self.__current_index >= len(self.documents): 137raise StopIteration 138else: 139self.__current_index += 1 140return self.documents[self.__current_index - 1] 141 142def __repr__(self): 143return f"Index({self.directory}): {self.documents}" 144 145def __len__(self): 146return len(self.documents) 147 148 149class Site: 150"""A type representing a website.""" 151def __init__(self, build_dir: typing.Union[str, bytes, os.PathLike], template_dir: typing.Union[str, bytes, os.PathLike] = "templates"): 152"""Create a new site object. 153 154:param build_dir: The directory to build the site in. 155:param template_dir: The directory to read the templates from. 156""" 157self.build_dir: str = build_dir 158self.template_engine: jinja2.Environment = jinja2.Environment(loader=jinja2.FileSystemLoader(template_dir)) 159self.pages: dict[str, typing.Union[Static, Page]] = {} 160self.context: dict[str, typing.Any] = {} 161 162def add_page(self, location: typing.Union[str, bytes, os.PathLike], page: typing.Union["Static", "Page"]): 163"""Add a page to the site. 164 165:param location: The location the page should be saved to. 166:param page: The page object itself. 167""" 168if location.endswith("/"): 169location += "index.html" 170location = location.lstrip("/") # interpret it as site root, not OS root 171self.pages[location] = page 172 173def add_from_index(self, index: Index, location: typing.Union[str, bytes, os.PathLike], template: typing.Union[str, None] = None, **kwargs): 174"""Add pages to the site from an index. 175 176:param index: The index to read the documents from. 177:param location: The location to save the pages to. 178:param template: The template to use for the pages. 179:param static: Whether to treat them as static files. 180:param kwargs: Additional keyword arguments to pass to the template when rendering. 181""" 182location = location.lstrip("/") # interpret it as site root, not OS root 183kwargs = {**self.context, **kwargs} 184if index.static: 185for document in index: 186self.pages[os.path.join(location, document.file_name)] = Static(self, document) 187else: 188for document in index: 189self.pages[os.path.join(location, document.file_name)] = Page(self, template, document, **kwargs) 190 191def filter(self, name: str): 192"""Decorator to add a filter to the template engine. 193 194:param name: The name the filter will be used with in Jinja2. 195""" 196def decorator(func): 197self.template_engine.filters[name] = func 198return func 199 200return decorator 201 202def test(self, name: str): 203"""Decorator to add a test to the template engine. 204 205:param name: The name the test will be used with in Jinja2. 206""" 207def decorator(func): 208self.template_engine.tests[name] = func 209return func 210 211return decorator 212 213def build(self, dont_delete: typing.Optional[list[str]] = None): 214"""Build the site in its directory.""" 215# Clear the build directory if it exists. 216if os.path.isdir(self.build_dir): 217_delete_directory_contents(self.build_dir, dont_delete=dont_delete) 218for location, page in self.pages.items(): 219# Create the required directories. 220os.makedirs(os.path.join(self.build_dir, os.path.dirname(location)), exist_ok=True) 221if isinstance(page, str): 222with open(os.path.join(self.build_dir, location), "w") as f: 223f.write(page) 224elif isinstance(page, bytes): 225with open(os.path.join(self.build_dir, location), "wb") as f: 226f.write(page) 227else: 228raise ValueError(f"{type(page)} cannot be used as a document") 229 230 231class Page(str): 232"""A type representing a page, which is a rendered template.""" 233def __new__(cls, site: Site, template: str, document: Document = None, **kwargs): 234kwargs = {**site.context, **kwargs} 235return site.template_engine.get_template(template).render(document=document, **kwargs) 236 237 238class Static(bytes): 239"""A type representing a static file, which is binary content that is not templated.""" 240def __new__(cls, site: Site, document: Document): 241return document.content 242