#!/usr/bin/env python3 import json import os import shutil from argparse import ArgumentParser from dataclasses import dataclass from datetime import datetime from pathlib import Path from loguru import logger from typst import Compiler, TypstError from watchfiles import DefaultFilter, watch @dataclass class CompiledPage: url: str title: str date: datetime | None class Builder: def __init__(self, site_dir: Path, static_dir: Path, public_dir: Path, temp_dir: Path) -> None: self.site_dir = site_dir self.content_dir = site_dir / 'content' self.static_dir = static_dir self.public_dir = public_dir self.temp_dir = temp_dir self._compiler = Compiler(root=site_dir) def _compile_typst_page(self, path: Path) -> CompiledPage: relative_path = path.relative_to(self.content_dir) logger.info(f'Typst: {relative_path}') output_path = self.public_dir / relative_path.with_suffix('.html') output_path.parent.mkdir(parents=True, exist_ok=True) self._compiler.compile( input=path, output=output_path, sys_inputs={ 'path': str(relative_path.with_suffix('')), }, ) metadata = json.loads( self._compiler.query( selector='', field='value', one=True, ) ) # FIXME: can we use one field for date? if 'date_year' in metadata: date = datetime( year=metadata['date_year'], month=metadata['date_month'], day=metadata['date_day'], ) else: date = None return CompiledPage( url=str(relative_path.with_suffix('.html')), title=metadata['title'], date=date, ) def _compile_typst_pages(self) -> list[CompiledPage]: return [self._compile_typst_page(path) for path in self.content_dir.rglob('*.typ')] def _compile_index_page(self, pages: list[CompiledPage]) -> None: payload_path = self.temp_dir / 'payload.json' payload_path.parent.mkdir(parents=True, exist_ok=True) # Maybe sorting/filtering should be done in Typst? pages = [ page for page in pages if page.date is not None ] pages.sort(key=lambda page: page.date, reverse=True) with payload_path.open('w') as file: payload = { 'pages': [ { 'url': page.url, 'title': page.title, 'year': page.date.year if page.date else None, 'month': page.date.month if page.date else None, 'day': page.date.day if page.date else None, } for page in pages ], } json.dump(payload, file) self._compiler.compile( input=self.site_dir / 'index.typ', output=self.public_dir / 'index.html', sys_inputs={ 'payload': str(payload_path.relative_to(self.site_dir)), }, ) def build(self) -> None: logger.info(f'Removing {str(self.public_dir)!r}...') shutil.rmtree(self.public_dir, ignore_errors=True) self.public_dir.mkdir(parents=True, exist_ok=True) logger.info('Linking static directory...') (self.public_dir / 'static').symlink_to(self.static_dir) pages = self._compile_typst_pages() logger.info('Building index page...') self._compile_index_page(pages) def main() -> None: parser = ArgumentParser() parser.add_argument('-w', '--watch', action='store_true', help='watch for changes and rebuild') args = parser.parse_args() repo_dir = Path(__file__).parent site_dir = repo_dir / 'site' public_dir = repo_dir / 'public' static_dir = repo_dir / 'static' temp_dir = site_dir / 'temp' os.environ['TYPST_FEATURES'] = 'html' builder = Builder(site_dir, static_dir, public_dir, temp_dir) builder.build() if args.watch: logger.info('Watching for changes...') watch_filter = DefaultFilter(ignore_paths=[temp_dir]) for changes in watch(site_dir, watch_filter=watch_filter): print(changes) try: builder.build() except TypstError as e: logger.exception(e) if __name__ == '__main__': main()