__init__.py
Python script, ASCII text executable
1#!/usr/bin/env python3 2 3import os 4import re 5import shutil 6import contextlib 7from datetime import datetime 8import jinja2 9from ruamel.yaml import YAML 10import colorama 11 12 13colorama.init() 14 15 16# Disable YAML date constructor 17def no_date_constructor(loader, node): 18value = loader.construct_scalar(node) 19return value 20 21 22@contextlib.contextmanager 23def in_directory(directory): 24cwd = os.getcwd() 25os.chdir(directory) 26try: 27yield 28finally: 29os.chdir(cwd) 30 31 32def delete_directory_contents(directory): 33for root, dirs, files in os.walk(directory): 34for file in files: 35os.remove(os.path.join(root, file)) 36for dir in dirs: 37shutil.rmtree(os.path.join(root, dir)) 38 39 40def parse_date_string(date_string): 41def split_date_and_time(date_string): 42if ":" not in date_string: 43return date_string, "00:00:00" 44 45elements = date_string.partition(":") 46partition_character = " " 47if " " not in date_string: 48partition_character = "-" 49if "-" not in date_string: 50partition_character = "T" 51 52date = elements[0].rpartition(partition_character)[0].strip() 53time = elements[0].rpartition(partition_character)[2].strip() + elements[1] + elements[2].strip() 54time = time.removeprefix("T").removesuffix("Z") 55 56return date, time 57 58time_formats = [ 59# 24-hour ISO 60"%H:%M:%S", 61"%H:%M", 62"%H", 63# Single digit hour 64"-%H:%M:%S", 65"-%H:%M", 66"-%H", 67# 12-hour (AM/PM) 68"%I:%M:%S %p", 69"%I:%M %p", 70"%I %p", 71# Single digit 12-hour 72"-%I:%M:%S %p", 73"-%I:%M %p", 74"-%I %p", 75] 76 77date_formats = [ 78# ISO formats 79"%Y-%m-%d", 80"%y-%m-%d", 81# European formats 82"%d.%m.%Y", 83"%d.%m.%y", 84# American formats 85"%m/%d/%Y", 86"%m/%d/%y", 87# Text-based European formats 88"%d %B %Y", 89"%d %b %Y", 90"%d %B, %Y", 91"%d %b, %Y", 92# Text-based American formats 93"%B %d %Y", 94"%b %d %Y", 95"%B %d, %Y", 96"%b %d, %Y", 97# ISO weekly calendar 98"%G-W%V-%u", 99] 100 101date, time = split_date_and_time(date_string) 102 103time_object = datetime.min.time() 104date_object = datetime.min.date() 105 106for time_format in time_formats: 107try: 108time_object = datetime.strptime(time, time_format) 109except ValueError: 110pass 111for date_format in date_formats: 112try: 113date_object = datetime.strptime(date, date_format) 114except ValueError: 115pass 116 117return datetime.combine(date_object, time_object.time()) 118 119 120class Document: 121def __init__(self, file_name, url_transform=lambda x: x): 122self.file_name = file_name 123self.encoding = "utf-8" 124# If the file is text, read it. 125self.front_matter = YAML() 126self.front_matter.Constructor.add_constructor("tag:yaml.org,2002:timestamp", no_date_constructor) 127self.content = "" 128self.date = datetime.fromtimestamp(os.path.getmtime(file_name)) 129try: 130with open(file_name, "r", encoding=self.encoding) as f: 131print(colorama.Style.RESET_ALL, colorama.Style.BRIGHT, colorama.Fore.LIGHTWHITE_EX, f"Loading document {file_name}".ljust(shutil.get_terminal_size().columns), sep="") 132 133# Parse front matter if available. 134front_matter = "" 135initial_line = f.readline() 136if initial_line == "---\n": 137print(colorama.Style.RESET_ALL, colorama.Fore.CYAN, "Front matter found", sep="") 138line = "" 139while line != "---\n": 140line = f.readline() 141if line != "---\n": 142front_matter += line 143print(colorama.Style.RESET_ALL, colorama.Fore.GREEN, "Front matter loaded", sep="") 144 145if front_matter: 146self.front_matter = self.front_matter.load(front_matter) 147 148print(self.front_matter, type(self.front_matter)) 149 150if "DATE" in self.front_matter: 151self.date = parse_date_string(self.front_matter["DATE"]) 152else: # put it back 153self.content = initial_line 154 155print(colorama.Style.RESET_ALL, colorama.Fore.CYAN, "Reading content", sep="") 156 157self.content += f.read() 158 159print(colorama.Style.RESET_ALL, colorama.Fore.GREEN, "Content loaded", sep="") 160print(colorama.Style.RESET_ALL, colorama.Style.DIM, self.content[:128] + "..." if len(self.content) > 128 else self.content) 161except UnicodeDecodeError: 162print(colorama.Style.RESET_ALL, colorama.Fore.CYAN, "Text decoding failed, assuming binary", sep="") 163self.encoding = None 164with open(file_name, "rb") as f: 165self.content = f.read() 166print(colorama.Style.RESET_ALL, colorama.Fore.GREEN, "Binary content loaded", sep="") 167 168print(colorama.Style.RESET_ALL, colorama.Fore.CYAN, colorama.Style.DIM, f"Transforming URL {self.file_name} ->", end=" ", sep="") 169self.file_name = url_transform(self.file_name) 170print(colorama.Style.RESET_ALL, colorama.Style.BRIGHT, colorama.Fore.LIGHTYELLOW_EX, self.file_name) 171 172print(colorama.Style.RESET_ALL, end="") 173 174def __repr__(self): 175return f"Document({self.file_name})" 176 177def __str__(self): 178return self.content 179 180def __getitem__(self, item): 181return self.front_matter[item] 182 183 184class Index: 185def __init__(self, directory, recursive=False, url_transform=lambda x: x, sort_by=lambda x: x.file_name, exclude=None): 186self.directory = directory 187# Temporarily move to the specified directory in order to read the files. 188if exclude: 189regex = re.compile(exclude) 190else: 191regex = re.compile("(?!)") 192with in_directory(directory): 193if recursive: 194self.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)] 195else: 196self.file_names = [i for i in os.listdir() if os.path.isfile(i) and not regex.search(i)] 197 198self.documents = sorted([Document(i, url_transform) for i in self.file_names], key=sort_by) 199self.__current_index = 0 200 201def __iter__(self): 202self.__current_index = 0 203return self 204 205def __next__(self): 206if self.__current_index >= len(self.documents): 207raise StopIteration 208else: 209self.__current_index += 1 210return self.documents[self.__current_index - 1] 211 212def __repr__(self): 213return f"Index({self.directory}): {self.documents}" 214 215def __len__(self): 216return len(self.documents) 217 218 219class Site: 220def __init__(self, build_dir, template_dir="templates"): 221self.build_dir = build_dir 222self.template_engine = jinja2.Environment(loader=jinja2.FileSystemLoader(template_dir)) 223self.pages = {} 224self.context = {} 225 226def add_page(self, location, page): 227if location.endswith("/"): 228location += "index.html" 229location = location.lstrip("/") # interpret it as site root, not OS root 230self.pages[location] = page 231 232def add_from_index(self, index, location, template, static=False, **kwargs): 233location = location.lstrip("/") # interpret it as site root, not OS root 234kwargs = {**self.context, **kwargs} 235if static: 236for document in index: 237self.pages[os.path.join(location, document.file_name)] = Static(self, document) 238else: 239for document in index: 240self.pages[os.path.join(location, document.file_name)] = Page(self, template, document, **kwargs) 241 242def filter(self, name): 243def decorator(func): 244self.template_engine.filters[name] = func 245return func 246 247return decorator 248 249def build(self): 250# Clear the build directory if it exists. 251if os.path.isdir(self.build_dir): 252delete_directory_contents(self.build_dir) 253for location, page in self.pages.items(): 254# Create the required directories. 255os.makedirs(os.path.join(self.build_dir, os.path.dirname(location)), exist_ok=True) 256if isinstance(page, str): 257with open(os.path.join(self.build_dir, location), "w") as f: 258f.write(page) 259elif isinstance(page, bytes): 260with open(os.path.join(self.build_dir, location), "wb") as f: 261f.write(page) 262else: 263raise ValueError(f"{type(page)} cannot be used as a document") 264 265 266class Page(str): 267def __new__(cls, site, template, document=None, **kwargs): 268kwargs = {**site.context, **kwargs} 269return site.template_engine.get_template(template).render(document=document, **kwargs) 270 271 272class Static(bytes): 273def __new__(cls, site, document): 274return document.content 275