diff --git a/Makefile b/Makefile index def07bd8..3f00b4ca 100644 --- a/Makefile +++ b/Makefile @@ -34,12 +34,16 @@ install-python: venv ## build: # Build assets - make pybuild + make pybuild-www + make pybuild-diypedals make messagesc -pybuild: +pybuild-www: ${bin}python -m tools.pybuild ./server/web/styles/styles.css --outdir ./server/web/static/css ${ARGS} +pybuild-diypedals: + ${bin}python -m tools.pybuild ./server/diypedals/web/styles/styles.css --outdir ./server/diypedals/web/static/css ${ARGS} + ## ## ---------------- ## Serving @@ -47,13 +51,16 @@ pybuild: ## start: # Run servers - make -j start-uvicorn start-assets + make -j start-uvicorn start-assets-www start-assets-diypedals start-uvicorn: PYTHONUNBUFFERED=1 ${bin}python -m server.main 2>&1 | ${bin}python -m tools.colorprefix blue [server] -start-assets: - PYTHONUNBUFFERED=1 make pybuild ARGS="--watch" 2>&1 | ${bin}python -m tools.colorprefix yellow [assets] +start-assets-www: + PYTHONUNBUFFERED=1 make pybuild-www ARGS="--watch" 2>&1 | ${bin}python -m tools.colorprefix yellow [assets-www] + +start-assets-diypedals: + PYTHONUNBUFFERED=1 make pybuild-diypedals ARGS="--watch" 2>&1 | ${bin}python -m tools.colorprefix red [assets-diypedals] ## ## ---------------- diff --git a/server/diypedals/.gitignore b/server/diypedals/.gitignore new file mode 100644 index 00000000..d7e1b6bc --- /dev/null +++ b/server/diypedals/.gitignore @@ -0,0 +1,2 @@ +cache/ +web/static/img/build_reports/ diff --git a/server/diypedals/__init__.py b/server/diypedals/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/diypedals/di.py b/server/diypedals/di.py new file mode 100644 index 00000000..a57baa19 --- /dev/null +++ b/server/diypedals/di.py @@ -0,0 +1,48 @@ +from typing import TypeVar + +import httpx + +from . import settings +from .domain.repositories import BuildReportRepository +from .infrastructure.cache import DiskCache +from .infrastructure.di import Container +from .infrastructure.webdav import BuildReportClient + +T = TypeVar("T") + + +def _configure(container: Container) -> None: + from .infrastructure.database import InMemoryDatabase + from .infrastructure.repositories import InMemoryBuildReportRepository + from .web.reload import HotReload + from .web.templating import Templates + + db = InMemoryDatabase() + container.register(InMemoryDatabase, instance=db) + + hotreload = HotReload() + container.register(HotReload, instance=hotreload) + + container.register(Templates, instance=Templates(hotreload)) + + container.register( + BuildReportRepository, instance=InMemoryBuildReportRepository(db) + ) + container.register( + BuildReportClient, + instance=BuildReportClient( + username=settings.WEBDAV_USERNAME, + password=str(settings.WEBDAV_PASSWORD), + cache=DiskCache(directory=settings.BUILD_REPORTS_CACHE_DIR), + ), + ) + + +def create_container() -> Container: + return Container(_configure) + + +_container = create_container() + +bootstrap = _container.bootstrap +resolve = _container.resolve diff --git a/server/diypedals/domain/__init__.py b/server/diypedals/domain/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/diypedals/domain/entities.py b/server/diypedals/domain/entities.py new file mode 100644 index 00000000..2238347b --- /dev/null +++ b/server/diypedals/domain/entities.py @@ -0,0 +1,37 @@ +import datetime as dt +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class Kit: + name: str + seller: str + url: str + + +@dataclass(frozen=True) +class Pcb: + name: str + seller: str + url: str + + +@dataclass(frozen=True) +class Photo: + filename: str + content_type: str + alt: str + + +@dataclass(frozen=True) +class BuildReport: + title: str + slug: str + description: str + categories: list[str] + build_date: dt.date + status: str + thumbnail: Photo + photos: list[Photo] = field(default_factory=list) + kit: Kit | None = None + pcb: Pcb | None = None diff --git a/server/diypedals/domain/repositories.py b/server/diypedals/domain/repositories.py new file mode 100644 index 00000000..650c9490 --- /dev/null +++ b/server/diypedals/domain/repositories.py @@ -0,0 +1,18 @@ +from .entities import BuildReport + + +class BuildReportRepository: + async def save(self, build_report: BuildReport) -> None: + raise NotImplementedError # pragma: no cover + + async def find_all(self, *, category: str | None = None) -> list[BuildReport]: + raise NotImplementedError # pragma: no cover + + async def find_one(self, *, slug: str) -> BuildReport | None: + raise NotImplementedError # pragma: no cover + + async def get_unique_categories(self) -> dict[str, int]: + raise NotImplementedError # pragma: no cover + + async def find_all_with_category(self, category: str) -> list[BuildReport]: + raise NotImplementedError # pragma: no cover diff --git a/server/diypedals/infrastructure/__init__.py b/server/diypedals/infrastructure/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/diypedals/infrastructure/cache.py b/server/diypedals/infrastructure/cache.py new file mode 100644 index 00000000..43849981 --- /dev/null +++ b/server/diypedals/infrastructure/cache.py @@ -0,0 +1,45 @@ +import json +import mimetypes +from pathlib import Path +from typing import Any, Callable + + +class DiskCache: + def __init__(self, directory: Path) -> None: + self._dir = directory + self._dir.mkdir(exist_ok=True) + self._file = self._dir / "cache.json" + self._data: dict = {} + + try: + self._data = json.loads(self._file.read_text()) + except FileNotFoundError: + self._file.write_text("{}") + + def _write_cache(self) -> None: + self._file.write_text(json.dumps(self._data)) + + async def get(self, key: str, fetch_func: Callable) -> Any: + try: + return self._data[key] + except KeyError: + value = await fetch_func() + self._data[key] = value + self._write_cache() + return value + + async def get_file( + self, path: Path | str, fetch_func: Callable + ) -> tuple[bytes, str]: + path = Path(path) + + try: + content = path.read_bytes() + except FileNotFoundError: + content, media_type = await fetch_func() + path.write_bytes(content) + else: + media_type = mimetypes.guess_type(path.name) + assert media_type is not None + + return content, media_type diff --git a/server/diypedals/infrastructure/content.py b/server/diypedals/infrastructure/content.py new file mode 100644 index 00000000..df686da0 --- /dev/null +++ b/server/diypedals/infrastructure/content.py @@ -0,0 +1,13 @@ +from .. import settings +from ..di import resolve +from ..domain.repositories import BuildReportRepository +from .cache import DiskCache +from .webdav import BuildReportClient + + +async def load_build_reports() -> None: + repository = resolve(BuildReportRepository) + client = resolve(BuildReportClient) + + async for build_report in client.fetch_all(): + await repository.save(build_report) diff --git a/server/diypedals/infrastructure/database.py b/server/diypedals/infrastructure/database.py new file mode 100644 index 00000000..38067818 --- /dev/null +++ b/server/diypedals/infrastructure/database.py @@ -0,0 +1,68 @@ +from contextlib import contextmanager +from typing import Iterator + +from .. import settings +from ..domain.entities import BuildReport +from .content import load_build_reports + + +class _Data: + def __init__(self) -> None: + self.build_reports: dict[str, BuildReport] = {} + + +class InMemoryDatabase: + def __init__(self) -> None: + self._data = _Data() + + async def connect(self) -> None: + await self._load() + + async def reload(self) -> None: + await self._load() # pragma: no cover + + @contextmanager + def isolated(self) -> Iterator[None]: + previous_data = self._data + self._data = _Data() + try: + yield + finally: + self._data = previous_data + + async def _load(self) -> None: + self._data = _Data() + + await load_build_reports() + + def find_all_build_reports( + self, *, category: str | None = None + ) -> list[BuildReport]: + queryset = (build_report for build_report in self._data.build_reports.values()) + + if category is not None: + queryset = ( + build_report + for build_report in queryset + if category in build_report.categories + ) + + return sorted( + queryset, key=lambda build_report: build_report.build_date, reverse=True + ) + + def find_one_build_report(self, slug: str) -> BuildReport | None: + return self._data.build_reports.get(slug) + + def insert_build_report(self, build_report: BuildReport) -> None: + self._data.build_reports[build_report.slug] = build_report + + def find_unique_build_report_categories(self) -> dict[str, int]: + categories_with_count: dict[str, int] = {} + + for build_report in self._data.build_reports.values(): + for category in build_report.categories: + categories_with_count.setdefault(category, 0) + categories_with_count[category] += 1 + + return categories_with_count diff --git a/server/diypedals/infrastructure/di.py b/server/diypedals/infrastructure/di.py new file mode 100644 index 00000000..354c7c8d --- /dev/null +++ b/server/diypedals/infrastructure/di.py @@ -0,0 +1,26 @@ +from typing import Callable, Type, TypeVar + +import punq + +T = TypeVar("T") + + +class Container: + def __init__(self, configure: Callable[["Container"], None]) -> None: + self._impl = punq.Container() + self._configure = configure + self._is_configured = False + + def register(self, type_: Type[T], *, instance: T) -> None: + self._impl.register(type_, instance=instance) + + def bootstrap(self) -> None: + if self._is_configured: + return # pragma: no cover + + self._configure(self) + self._is_configured = True + + def resolve(self, type_: Type[T]) -> T: + assert self._is_configured, "DI not configured: call bootstrap()" + return self._impl.resolve(type_) diff --git a/server/diypedals/infrastructure/repositories.py b/server/diypedals/infrastructure/repositories.py new file mode 100644 index 00000000..0a834d70 --- /dev/null +++ b/server/diypedals/infrastructure/repositories.py @@ -0,0 +1,20 @@ +from ..domain.entities import BuildReport +from ..domain.repositories import BuildReportRepository +from .database import InMemoryDatabase + + +class InMemoryBuildReportRepository(BuildReportRepository): + def __init__(self, db: InMemoryDatabase) -> None: + self._db = db + + async def save(self, build_report: BuildReport) -> None: + self._db.insert_build_report(build_report) + + async def find_all(self, *, category: str | None = None) -> list[BuildReport]: + return self._db.find_all_build_reports(category=category) + + async def find_one(self, *, slug: str) -> BuildReport | None: + return self._db.find_one_build_report(slug) + + async def get_unique_categories(self) -> dict[str, int]: + return self._db.find_unique_build_report_categories() diff --git a/server/diypedals/infrastructure/urls.py b/server/diypedals/infrastructure/urls.py new file mode 100644 index 00000000..f36a986b --- /dev/null +++ b/server/diypedals/infrastructure/urls.py @@ -0,0 +1,27 @@ +from typing import Any + +import httpx + +from .. import settings +from ..domain.entities import BuildReport + + +def to_production_url(url: str) -> str: + urlobj = httpx.URL(url) + + scheme, host, port = ( + ("http", f"localhost", settings.PORT) + if settings.DEBUG + else ("http" if settings.TESTING else "https", f"florimond.dev", None) + ) + + path = "/diypedals" + urlobj.path + + return str(urlobj.copy_with(scheme=scheme, host=host, port=port, path=path)) + + +def get_absolute_path(obj: Any) -> str: + if isinstance(obj, BuildReport): + return f"/build-reports/{obj.slug}" + + raise ValueError(f"No absolute URL defined for {obj!r}") diff --git a/server/diypedals/infrastructure/webdav.py b/server/diypedals/infrastructure/webdav.py new file mode 100644 index 00000000..3bce33cc --- /dev/null +++ b/server/diypedals/infrastructure/webdav.py @@ -0,0 +1,220 @@ +import datetime as dt +import http +import mimetypes +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from typing import Any, AsyncIterator + +import httpx +from PIL import Image + +from .. import settings +from ..domain.entities import BuildReport, Kit, Pcb, Photo +from .cache import DiskCache + + +@dataclass(frozen=True) +class _Folder: + href: str + etag: str + display_name: str + + +@dataclass(frozen=True) +class _PhotoFile: + media_type: str + href: str + etag: str + display_name: str + + +class BuildReportClient: + def __init__(self, username: str, password: str, cache: DiskCache) -> None: + self._cache = cache + self._http = httpx.AsyncClient( + auth=httpx.BasicAuth(username, password), + timeout=httpx.Timeout(5, connect=15, read=15), + ) + # https://docs.nextcloud.com/server/latest/developer_manual/client_apis/WebDAV/basic.html + self._url = httpx.URL(settings.BUILD_REPORTS_WEBDAV_URL) + self._images_dir = settings.BUILD_REPORTS_IMAGES_DIR + self._images_dir.mkdir(exist_ok=True) + + async def fetch_all(self) -> AsyncIterator[BuildReport]: + folders = await self._list_folders() + + for folder in folders: + entry = await self._read_entry(folder) + photos = await self._read_photos(folder) + thumbnail = _make_thumbnail(photos[0]) + + yield BuildReport( + title=entry["title"], + slug=entry["slug"], + description=entry["description"], + categories=entry["categories"], + build_date=dt.date.fromisoformat(entry["build_date"]), + status=entry["status"], + thumbnail=thumbnail, + photos=photos, + kit=Kit(**entry["kit"]) if entry.get("kit") else None, + pcb=Pcb(**entry["pcb"]) if entry.get("pcb") else None, + ) + + async def _list_folders(self) -> list[_Folder]: + body = """ + + + + + + + + """ + response = await self._http.request("PROPFIND", self._url, content=body) + response.raise_for_status() + assert response.status_code == http.HTTPStatus.MULTI_STATUS.value + + xml = ET.fromstring(response.text) + + folders = [] + + for i, item in enumerate(xml): + if i == 0: + # Skip the folder itself + continue + + if item.find(".//d:resourcetype/d:collection", {"d": "DAV:"}) is None: + continue + + href_el = item.find(".//d:href", {"d": "DAV:"}) + assert href_el is not None and href_el.text is not None + + etag_el = item.find(".//d:getetag", {"d": "DAV:"}) + assert etag_el is not None and etag_el.text is not None + + display_name_el = item.find(".//d:displayname", {"d": "DAV:"}) + assert display_name_el is not None and display_name_el.text is not None + + folder = _Folder( + href=href_el.text, + etag=etag_el.text, + display_name=display_name_el.text, + ) + + folders.append(folder) + + return folders + + async def _read_entry(self, folder: _Folder) -> dict[str, Any]: + async def fetch() -> dict: + url = str(self._url.copy_with(path=folder.href + "entry.json")) + r = await self._http.request("GET", url) + r.raise_for_status() + return r.json() + + return await self._cache.get(f"entry-{folder.etag}", lambda: fetch()) + + async def _list_photo_files(self, folder: _Folder) -> list[_PhotoFile]: + body = """ + + + + + + + + + """ + url = str(self._url.copy_with(path=folder.href + "photos")) + response = await self._http.request("PROPFIND", url, content=body) + response.raise_for_status() + assert response.status_code == http.HTTPStatus.MULTI_STATUS.value + + xml = ET.fromstring(response.text) + + photo_files = [] + + for i, item in enumerate(xml): + if i == 0: + # Skip the folder itself + continue + + if item.find(".//d:resourcetype/d:collection", {"d": "DAV:"}) is not None: + continue + + media_type_el = item.find(".//d:getcontenttype", {"d": "DAV:"}) + assert media_type_el is not None and media_type_el.text is not None + + if not media_type_el.text.startswith("image/"): + continue + + href_el = item.find(".//d:href", {"d": "DAV:"}) + assert href_el is not None and href_el.text is not None + + etag_el = item.find(".//d:getetag", {"d": "DAV:"}) + assert etag_el is not None and etag_el.text is not None + + display_name_el = item.find(".//d:displayname", {"d": "DAV:"}) + assert display_name_el is not None and display_name_el.text is not None + + photo_file = _PhotoFile( + media_type=media_type_el.text, + href=href_el.text, + etag=etag_el.text, + display_name=folder.display_name + "-" + display_name_el.text, + ) + + photo_files.append(photo_file) + + return photo_files + + async def _read_photos(self, folder: _Folder) -> list[Photo]: + async def fetch() -> list[dict]: + return [ + await self._read_photo_serializable(photo_file) + for photo_file in await self._list_photo_files(folder) + ] + + attrs = await self._cache.get(f"photos-{folder.etag}", lambda: fetch()) + + return [Photo(**attr) for attr in attrs] + + async def _read_photo_serializable(self, photo_file: _PhotoFile) -> dict: + async def fetch() -> dict: + static_path = self._images_dir / photo_file.display_name + + try: + content = static_path.read_bytes() + media_type, _ = mimetypes.guess_type(static_path.name) + assert media_type is not None + except FileNotFoundError: + url = self._url.copy_with(path=photo_file.href) + response = await self._http.request("GET", url) + content = response.content + media_type = response.headers["Content-Type"] + static_path.write_bytes(content) + + return { + "filename": str(static_path.relative_to(settings.STATIC_DIR)), + "content_type": media_type, + "alt": "Photo", + } + + return await self._cache.get(photo_file.etag, lambda: fetch()) + + +def _make_thumbnail(photo: Photo) -> Photo: + img = Image.open(settings.STATIC_DIR / photo.filename) + + img.thumbnail((360, 240)) + + img_path = settings.STATIC_DIR / photo.filename + thumbnail_path = img_path.parent / f"{img_path.stem}_thumbnail{img_path.suffix}" + img.save(thumbnail_path, format="JPEG") + + return Photo( + filename=str(thumbnail_path.relative_to(settings.STATIC_DIR)), + content_type="image/jpeg", + alt="Thumbnail", # TODO + ) diff --git a/server/diypedals/settings.py b/server/diypedals/settings.py new file mode 100644 index 00000000..07489203 --- /dev/null +++ b/server/diypedals/settings.py @@ -0,0 +1,25 @@ +import pathlib + +from starlette.config import Config +from starlette.datastructures import Secret + +config = Config(".env") + +HERE = pathlib.Path(__file__).parent + +HOST: str = config("WWW_HOST", cast=str, default="localhost") +PORT: int = config("WWW_PORT", cast=int, default=8000) + +DEBUG: bool = config("WWW_DEBUG", cast=bool, default=False) +TESTING: bool = config("WWW_TESTING", cast=bool, default=False) + +STATIC_ROOT = "/static" +STATIC_DIR = HERE / "web" / "static" +TEMPLATES_DIR = HERE / "web" / "templates" + +WEBDAV_USERNAME = config("DIYPEDALS_WEBDAV_USERNAME") +WEBDAV_PASSWORD: Secret = config("DIYPEDALS_WEBDAV_PASSWORD", cast=Secret) + +BUILD_REPORTS_WEBDAV_URL = config("DIYPEDALS_BUILD_REPORTS_WEBDAV_URL") +BUILD_REPORTS_IMAGES_DIR = STATIC_DIR / "img" / "build_reports" +BUILD_REPORTS_CACHE_DIR = HERE / "cache" diff --git a/server/diypedals/web/__init__.py b/server/diypedals/web/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/diypedals/web/app.py b/server/diypedals/web/app.py new file mode 100644 index 00000000..a693d8f7 --- /dev/null +++ b/server/diypedals/web/app.py @@ -0,0 +1,38 @@ +from contextlib import asynccontextmanager +from typing import AsyncIterator + +from starlette.routing import BaseRoute + +from .. import settings +from ..di import resolve +from ..infrastructure.database import InMemoryDatabase +from .reload import HotReload +from .routes import get_routes + + +class DiyPedals: + def __init__(self) -> None: + self._routes = get_routes() + + @asynccontextmanager + async def lifespan(self) -> AsyncIterator[None]: + db = resolve(InMemoryDatabase) + hotreload = resolve(HotReload) + + await db.connect() + + if settings.DEBUG: # pragma: no cover + await hotreload.startup() + + yield + + if settings.DEBUG: # pragma: no cover + await hotreload.shutdown() + + @property + def routes(self) -> list[BaseRoute]: + return self._routes + + +def create_app() -> DiyPedals: + return DiyPedals() diff --git a/server/diypedals/web/reload.py b/server/diypedals/web/reload.py new file mode 100644 index 00000000..b4478344 --- /dev/null +++ b/server/diypedals/web/reload.py @@ -0,0 +1,17 @@ +from pathlib import Path + +import arel + +from .. import settings + +HERE = Path(__file__).parent + + +class HotReload(arel.HotReload): + def __init__(self) -> None: + super().__init__( + paths=[ + arel.Path(str(settings.TEMPLATES_DIR)), + arel.Path(str(settings.STATIC_DIR)), + ] + ) diff --git a/server/diypedals/web/routes.py b/server/diypedals/web/routes.py new file mode 100644 index 00000000..7d4b3cec --- /dev/null +++ b/server/diypedals/web/routes.py @@ -0,0 +1,44 @@ +from starlette.routing import BaseRoute, Host, Mount, Route, Router, WebSocketRoute + +from .. import settings +from ..di import resolve +from . import views +from .reload import HotReload +from .statics import CachedStaticFiles + + +def get_routes() -> list[BaseRoute]: + static = CachedStaticFiles( + directory=str(settings.STATIC_DIR), + max_age=7 * 86400, # 7 days + ) + + hotreload = resolve(HotReload) + + routes = [ + Mount(settings.STATIC_ROOT, static, name="static"), + # These files need to be exposed at the root, not '/static/'. + Route("/favicon.ico", static, name="favicon"), + Route("/robots.txt", static, name="robots"), + Route("/", views.Home, name="home"), + Route( + "/build-reports", + views.BuildReportList, + name="build_reports", + ), + Route( + "/build-reports/{slug:str}", + views.BuildReportDetail, + name="build_reports:detail", + ), + Route( + "/build-reports/categories/{category:str}", + views.BuildReportCategoryDetail, + name="build_reports:categories:detail", + ), + ] + + if settings.DEBUG: # pragma: no cover + routes += [WebSocketRoute("/hot-reload", hotreload, name="hot-reload")] + + return routes diff --git a/server/diypedals/web/static/.gitignore b/server/diypedals/web/static/.gitignore new file mode 100644 index 00000000..276eea90 --- /dev/null +++ b/server/diypedals/web/static/.gitignore @@ -0,0 +1,2 @@ +css/styles.css +img/build_reports/*.jpg diff --git a/server/diypedals/web/static/apple-touch-icon.png b/server/diypedals/web/static/apple-touch-icon.png new file mode 100644 index 00000000..0afc7508 Binary files /dev/null and b/server/diypedals/web/static/apple-touch-icon.png differ diff --git a/server/diypedals/web/static/css/.gitkeep b/server/diypedals/web/static/css/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/server/diypedals/web/static/favicon.ico b/server/diypedals/web/static/favicon.ico new file mode 100644 index 00000000..d47db764 Binary files /dev/null and b/server/diypedals/web/static/favicon.ico differ diff --git a/server/diypedals/web/static/img/build_reports/.gitkeep b/server/diypedals/web/static/img/build_reports/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/server/diypedals/web/static/manifest.json b/server/diypedals/web/static/manifest.json new file mode 100644 index 00000000..cd57790c --- /dev/null +++ b/server/diypedals/web/static/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "diypedals.fm: DIY Guitar Pedals by Florimond MAnca", + "short_name": "diypedals.fm", + "theme_color": "#59c6b6", + "background_color": "#354249", + "display": "standalone", + "scope": "/", + "start_url": "/", + "icons": [ + { + "src": "img/icon-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "img/icon-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/server/diypedals/web/static/robots.txt b/server/diypedals/web/static/robots.txt new file mode 100644 index 00000000..eb053628 --- /dev/null +++ b/server/diypedals/web/static/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/server/diypedals/web/statics.py b/server/diypedals/web/statics.py new file mode 100644 index 00000000..0718b346 --- /dev/null +++ b/server/diypedals/web/statics.py @@ -0,0 +1,19 @@ +from typing import Any + +from starlette.responses import Response +from starlette.staticfiles import StaticFiles + + +class CachedStaticFiles(StaticFiles): + def __init__(self, *args: Any, max_age: int, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._max_age = max_age + + def file_response(self, *args: Any, **kwargs: Any) -> Response: + response = super().file_response(*args, **kwargs) + + # Cache all static assets. + response.headers.append("Cache-Control", f"public, max-age={self._max_age}") + response.headers.append("Vary", "Accept-Encoding, User-Agent, Cookie, Referer") + + return response diff --git a/server/diypedals/web/styles/defaults.css b/server/diypedals/web/styles/defaults.css new file mode 100644 index 00000000..6f057909 --- /dev/null +++ b/server/diypedals/web/styles/defaults.css @@ -0,0 +1,137 @@ +html { + font-size: var(--font-base); +} + +body { + font-family: var(--font-family); + font-size: var(--font-size-default); + background-color: var(--background-default); + color: var(--text-on-background-default); + line-height: 1.5; + padding: 0; + margin: 0; + display: flex; + flex-flow: column; + min-height: 100vh; + align-self: stretch; +} + +h1 { + font-family: var(--font-heading); + font-size: var(--font-size-h1); + font-weight: bold; + margin-top: 0; + margin-bottom: var(--w); + line-height: 1.25; +} + +h2 { + font-family: var(--font-heading); + font-size: var(--font-size-h2); + font-weight: normal; + margin-top: 0; + margin-bottom: var(--w); +} + +h3, .f-h3 { + font-family: var(--font-heading); + font-size: var(--font-size-h3); + font-weight: bold; + margin-top: 0; + margin-bottom: var(--w); +} + +p { + margin-top: 0; + margin-bottom: var(--w); +} + +ul { + list-style-type: disc; +} + +.f-raw-list { + list-style-type: none; + padding: 0; + margin: 0; +} + +a { + color: inherit; +} + +a:visited { + color: inherit; +} + +a:hover { + color: var(--primary); +} + +hr { + background-color: var(--muted); +} + +blockquote { + margin-left: var(--w); + padding-block: var(--w); + padding-left: calc(3 * var(--w)); + border-left: 1px solid var(--muted); + position: relative; +} + +blockquote:before { + /* White rectangle for quotation mark to sit on */ + content: ""; + background-color: var(--background-default); + position: absolute; + top: 50%; + left: -4px; + height: 2rem; + width: 5px; + margin-top: -1rem; +} + +blockquote:after { + /* Quotation mark */ + content: "\201C"; + position: absolute; + top: 50%; + left: -0.5em; + line-height: 1em; + text-align: center; + text-indent: -2px; + width: 1em; + margin-top: -0.4em; + color: var(--muted); + font-size: var(--font-size-blockquote-mark); +} + +blockquote > cite { + font-style: italic; +} + +/* Tables */ +/* Inspired by: https://github.com/vuejs/vuepress/blob/1a930dc22e4298b648dad37fa07e2442cdcb3c10/packages/%40vuepress/theme-default/styles/index.styl#L163-L177 */ + +table { + border-collapse: collapse; + display: block; + margin: 1rem 0; + overflow-x: auto; +} +tr { + border-top: 1px solid #dfe2e5; +} +tr:nth-child(2n) { + background-color: #f6f8fa; +} +th, +td { + border: 1px solid #dfe2e5; + padding: 0.6em 1em; +} + +[aria-current="page"] { + font-weight: bold; +} \ No newline at end of file diff --git a/server/diypedals/web/styles/layouts/box.css b/server/diypedals/web/styles/layouts/box.css new file mode 100644 index 00000000..b2ffd275 --- /dev/null +++ b/server/diypedals/web/styles/layouts/box.css @@ -0,0 +1,9 @@ +.f-box { + padding: var(--box-padding, 0); +} + +@media screen and (min-width: 900px) { + .f-box { + padding: var(--box-padding-desktop, var(--box-padding, 0)); + } +} diff --git a/server/diypedals/web/styles/layouts/cluster.css b/server/diypedals/web/styles/layouts/cluster.css new file mode 100644 index 00000000..e105988a --- /dev/null +++ b/server/diypedals/web/styles/layouts/cluster.css @@ -0,0 +1,7 @@ +.f-cluster { + display: flex; + flex-wrap: var(--cluster-wrap, wrap); + justify-content: var(--cluster-justify, flex-start); + align-content: var(--cluster-align, flex-start); + gap: var(--cluster-gap, 0); +} diff --git a/server/diypedals/web/styles/layouts/container.css b/server/diypedals/web/styles/layouts/container.css new file mode 100644 index 00000000..23e1e21c --- /dev/null +++ b/server/diypedals/web/styles/layouts/container.css @@ -0,0 +1,14 @@ +.f-container { + max-width: var(--container-width, 100ch); + margin-inline: auto; + padding-inline: var(--container-padding, calc(2 * var(--w))); +} + +@media screen and (min-width: 900px) { + .f-container { + padding-inline: var( + --container-padding-desktop, + var(--container-padding, calc(3 * var(--w))) + ); + } +} diff --git a/server/diypedals/web/styles/layouts/frame.css b/server/diypedals/web/styles/layouts/frame.css new file mode 100644 index 00000000..409518c1 --- /dev/null +++ b/server/diypedals/web/styles/layouts/frame.css @@ -0,0 +1,15 @@ +.f-frame { + aspect-ratio: var(--frame-ratio); + max-width: var(--frame-width, 100%); + overflow: hidden; + display: flex; + justify-content: center; + align-items: center; +} + +.f-frame > img { + /* Cover entire frame, image will be scaled if necessary */ + inline-size: 100%; + block-size: 100%; + object-fit: cover; +} diff --git a/server/diypedals/web/styles/layouts/grid.css b/server/diypedals/web/styles/layouts/grid.css new file mode 100644 index 00000000..458741be --- /dev/null +++ b/server/diypedals/web/styles/layouts/grid.css @@ -0,0 +1,10 @@ +.f-grid { + display: grid; + grid-gap: 1rem; +} + +@supports (width: min(250px, 100%)) { + .f-grid { + grid-template-columns: repeat(auto-fit, minmax(min(var(--grid-min, 250px), 100%), var(--grid-max, 1fr))); + } +} \ No newline at end of file diff --git a/server/diypedals/web/styles/layouts/index.css b/server/diypedals/web/styles/layouts/index.css new file mode 100644 index 00000000..a0439a12 --- /dev/null +++ b/server/diypedals/web/styles/layouts/index.css @@ -0,0 +1,7 @@ +@import './box.css'; +@import './cluster.css'; +@import './container.css'; +@import './frame.css'; +@import './grid.css'; +@import './sidebar.css'; +@import './stack.css'; \ No newline at end of file diff --git a/server/diypedals/web/styles/layouts/sidebar.css b/server/diypedals/web/styles/layouts/sidebar.css new file mode 100644 index 00000000..97178ddf --- /dev/null +++ b/server/diypedals/web/styles/layouts/sidebar.css @@ -0,0 +1,15 @@ +.f-with-sidebar-left, .f-with-sidebar-right { + display: flex; + flex-wrap: wrap; + gap: var(--sidebar-gap, 0); +} + +.f-with-sidebar-left > :first-child, .f-with-sidebar-right > :last-child { + flex-grow: 1; +} + +.f-with-sidebar-left > :last-child, .f-with-sidebar-right > :first-child { + flex-basis: 0; + flex-grow: 999; + min-inline-size: var(--sidebar-content-min, 50%); +} diff --git a/server/diypedals/web/styles/layouts/stack.css b/server/diypedals/web/styles/layouts/stack.css new file mode 100644 index 00000000..b7dbb824 --- /dev/null +++ b/server/diypedals/web/styles/layouts/stack.css @@ -0,0 +1,7 @@ +.f-stack > * { + margin-bottom: 0; +} + +.f-stack > * + * { + margin-top: var(--stack-gap, calc(2 * var(--w))); +} diff --git a/server/diypedals/web/styles/reset.css b/server/diypedals/web/styles/reset.css new file mode 100644 index 00000000..fc50671f --- /dev/null +++ b/server/diypedals/web/styles/reset.css @@ -0,0 +1,70 @@ +/* https://www.joshwcomeau.com/css/custom-css-reset/ */ + +/* + 1. Use a more-intuitive box-sizing model. +*/ +*, +*::before, +*::after { + box-sizing: border-box; +} + +/* + 2. Remove default margin + */ +* { + margin: 0; +} + +/* + Typographic tweaks! + 3. Add accessible line-height + 4. Improve text rendering + */ +body { + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +/* + 5. Improve media defaults + */ +img, +picture, +video, +canvas, +svg { + display: block; + max-width: 100%; +} + +/* + 6. Remove built-in form typography styles + */ +input, +button, +textarea, +select { + font: inherit; +} + +/* + 7. Avoid text overflows + */ +p, +h1, +h2, +h3, +h4, +h5, +h6 { + overflow-wrap: break-word; +} + +/* + 8. Create a root stacking context + */ +#root, +#__next { + isolation: isolate; +} \ No newline at end of file diff --git a/server/diypedals/web/styles/styles.css b/server/diypedals/web/styles/styles.css new file mode 100644 index 00000000..ae87e376 --- /dev/null +++ b/server/diypedals/web/styles/styles.css @@ -0,0 +1,5 @@ +@import './reset.css'; +@import './variables.css'; +@import './defaults.css'; +@import './layouts/index.css'; +@import './utilities/index.css'; diff --git a/server/diypedals/web/styles/utilities/background.css b/server/diypedals/web/styles/utilities/background.css new file mode 100644 index 00000000..6c6f2e1c --- /dev/null +++ b/server/diypedals/web/styles/utilities/background.css @@ -0,0 +1,30 @@ +.f-background-default { + background-color: var(--background-default); +} + +.f-background-alt-grey { + background-color: var(--background-alt-grey); +} + +.f-background-alt-yellow { + background-color: var(--background-alt-yellow); +} + +.f-background-wavy-yellow { + position: relative; +} + +.f-background-wavy-yellow::before { + --color1: var(--background-alt-yellow); + --color2: var(--background-alt-yellow-darker); + content: ''; + position: absolute; + z-index: -1; + width: 100%; + height: 100%; + background-color: #fff0c9; + background-image: linear-gradient(135deg, var(--color2) 25%, transparent 25%), linear-gradient(225deg, var(--color2) 25%, transparent 25%), linear-gradient(45deg, var(--color2) 25%, transparent 25%), linear-gradient(315deg, var(--color2) 25%, var(--color1) 25%); + background-position: 20px 0, 20px 0, 0 0, 0 0; + background-size: 60px 60px; + background-repeat: repeat; +} \ No newline at end of file diff --git a/server/diypedals/web/styles/utilities/index.css b/server/diypedals/web/styles/utilities/index.css new file mode 100644 index 00000000..164770dd --- /dev/null +++ b/server/diypedals/web/styles/utilities/index.css @@ -0,0 +1,3 @@ +@import './background.css'; +@import './shadow.css'; +@import './sizing.css'; diff --git a/server/diypedals/web/styles/utilities/shadow.css b/server/diypedals/web/styles/utilities/shadow.css new file mode 100644 index 00000000..c03c8125 --- /dev/null +++ b/server/diypedals/web/styles/utilities/shadow.css @@ -0,0 +1,3 @@ +.f-shadow { + box-shadow: var(--shadow-default); +} \ No newline at end of file diff --git a/server/diypedals/web/styles/utilities/sizing.css b/server/diypedals/web/styles/utilities/sizing.css new file mode 100644 index 00000000..9159fc98 --- /dev/null +++ b/server/diypedals/web/styles/utilities/sizing.css @@ -0,0 +1,3 @@ +.f-w-full { + width: 100%; +} \ No newline at end of file diff --git a/server/diypedals/web/styles/variables.css b/server/diypedals/web/styles/variables.css new file mode 100644 index 00000000..a4c63499 --- /dev/null +++ b/server/diypedals/web/styles/variables.css @@ -0,0 +1,44 @@ +:root { + --v: 4px; + --w: 8px; + --1w: 8px; + --2w: 16px; + --4w: 32px; + + /* Colors */ + --surface: #edeff0; + --muted: #6b6b6b; + --danger: #ff0000; + --primary: #1866ac; + --background-default: white; + --background-alt-grey: #eee; + --background-alt-yellow: #fff0c9; + --background-alt-yellow-darker: #f5e6bf; + --text-on-background-default: #424242; + + /* Fonts */ + --font-family: sans-serif; + --font-heading: var(--font-family); + --font-base: 16px; + --font-size-default: 1rem; + --font-size-sm: 0.83rem; + --font-size-lg: 1.25rem; + --font-size-h1: 1.9rem; + --font-size-h2: 1.5rem; + --font-size-h3: 1.16rem; + --font-size-blockquote-mark: 1.6rem; + + /* Shadows */ + --shadow-default: 0px 0px 20px 0px #49495a26; + + /* Borders */ + --radius-sm: 5px; +} + +@media screen and (min-width: 900px) { + :root { + --font-size-h1: 2.9rem; + --font-size-h2: 2.1rem; + --font-size-h3: 1.5rem; + } +} diff --git a/server/diypedals/web/templates/base.jinja b/server/diypedals/web/templates/base.jinja new file mode 100644 index 00000000..f9c58812 --- /dev/null +++ b/server/diypedals/web/templates/base.jinja @@ -0,0 +1,53 @@ + + + + + + + + {% block title %}diypedals.fm{% endblock title %} + + + + + + + + + + + + + + + + + + + + {# {% if settings.WEBMENTIONS_URL %} + + + {% endif %} #} + + {% block head %}{% endblock head %} + + + + {% block body %}{% endblock %} + + + + {% if settings.DEBUG %} + {{ hotreload.script(url_for('diypedals:hot-reload')) | safe }} + {% endif %} + + + + + {% block tail %}{% endblock %} + + diff --git a/server/diypedals/web/templates/layouts/default.jinja b/server/diypedals/web/templates/layouts/default.jinja new file mode 100644 index 00000000..b9e2336b --- /dev/null +++ b/server/diypedals/web/templates/layouts/default.jinja @@ -0,0 +1,26 @@ +{% extends 'base.jinja'%} + +{% set _body_class = "f-background-wavy-yellow" %} + +{% block body %} +
+ + +
+ {% block main %}{% endblock %} +
+
+{% endblock body %} \ No newline at end of file diff --git a/server/diypedals/web/templates/views/build_reports/_category_list.jinja b/server/diypedals/web/templates/views/build_reports/_category_list.jinja new file mode 100644 index 00000000..1ee50a3d --- /dev/null +++ b/server/diypedals/web/templates/views/build_reports/_category_list.jinja @@ -0,0 +1,15 @@ +{% macro CategoryList(build_report) %} + + + + +{% endmacro %} diff --git a/server/diypedals/web/templates/views/build_reports/_category_sidebar.jinja b/server/diypedals/web/templates/views/build_reports/_category_sidebar.jinja new file mode 100644 index 00000000..33540ec9 --- /dev/null +++ b/server/diypedals/web/templates/views/build_reports/_category_sidebar.jinja @@ -0,0 +1,14 @@ +{% macro CategorySidebar() %} + +{% endmacro %} diff --git a/server/diypedals/web/templates/views/build_reports/_detail_photos.jinja b/server/diypedals/web/templates/views/build_reports/_detail_photos.jinja new file mode 100644 index 00000000..1c2a64ec --- /dev/null +++ b/server/diypedals/web/templates/views/build_reports/_detail_photos.jinja @@ -0,0 +1,7 @@ + diff --git a/server/diypedals/web/templates/views/build_reports/_list_view.jinja b/server/diypedals/web/templates/views/build_reports/_list_view.jinja new file mode 100644 index 00000000..a3aa3ae9 --- /dev/null +++ b/server/diypedals/web/templates/views/build_reports/_list_view.jinja @@ -0,0 +1,29 @@ +{% from 'views/build_reports/_category_list.jinja' import CategoryList with context %} + +{% macro ListView(build_reports) %} + +{% endmacro %} \ No newline at end of file diff --git a/server/diypedals/web/templates/views/build_reports/categories/detail.jinja b/server/diypedals/web/templates/views/build_reports/categories/detail.jinja new file mode 100644 index 00000000..622e60c0 --- /dev/null +++ b/server/diypedals/web/templates/views/build_reports/categories/detail.jinja @@ -0,0 +1,23 @@ +{% extends 'layouts/default.jinja' %} +{% from 'views/build_reports/_list_view.jinja' import ListView with context %} +{% from 'views/build_reports/_category_sidebar.jinja' import CategorySidebar with context %} + +{% set title = category|capitalize + ' pedals' %} + +{% block title %} + {{ title }} - Categories - Build reports - {{ super() }} +{% endblock %} + +{% block main %} +

{{ title }}

+ +
+
+

Build reports in the category "{{ category }}".

+ + {{ ListView(build_reports) }} +
+ + {{ CategorySidebar() }} +
+{% endblock %} diff --git a/server/diypedals/web/templates/views/build_reports/detail.jinja b/server/diypedals/web/templates/views/build_reports/detail.jinja new file mode 100644 index 00000000..5e7129e3 --- /dev/null +++ b/server/diypedals/web/templates/views/build_reports/detail.jinja @@ -0,0 +1,56 @@ +{% extends 'layouts/default.jinja' %} +{% from 'views/build_reports/_category_list.jinja' import CategoryList with context %} + +{% block title %} + {{ build_report.title }} - Build reports - {{ super() }} +{% endblock %} + +{% block main %} +

{{ build_report.title }}

+ + {{ CategoryList(build_report) }} + +

Overview

+ +

Description: {{ build_report.description }}

+ +

Built: {{ build_report.build_date|dateformat }}

+ + {% if build_report.kit %} +

Kit: {{ build_report.kit.name }} ({{ build_report.kit.seller }})

+ {% endif %} + + {% if build_report.pcb %} +

PCB: {{ build_report.pcb.name }} ({{ build_report.pcb.seller }})

+ {% endif %} + +

Photos

+ +
+ {% if show_photos %} +
+ +
+ + + {% else %} +
+ + +
+ + {% set photo = build_report.photos|first %} +
+
+ {{ photo.alt }} +
+
+ {% endif %} +
+{% endblock %} diff --git a/server/diypedals/web/templates/views/build_reports/list.jinja b/server/diypedals/web/templates/views/build_reports/list.jinja new file mode 100644 index 00000000..479707cc --- /dev/null +++ b/server/diypedals/web/templates/views/build_reports/list.jinja @@ -0,0 +1,40 @@ +{% extends 'layouts/default.jinja' %} +{% from 'views/build_reports/_category_sidebar.jinja' import CategorySidebar with context %} +{% from 'views/build_reports/_list_view.jinja' import ListView with context %} + +{% block title %} + Build reports - {{ super() }} +{% endblock %} + +{% block main %} +

Build reports

+ +
+
+

Latest build reports

+ + {{ ListView(build_reports) }} +
+ + {{ CategorySidebar() }} +
+ +

Upcoming builds

+ + + +

Tips and tutorials

+ + +{% endblock %} \ No newline at end of file diff --git a/server/diypedals/web/templates/views/index.jinja b/server/diypedals/web/templates/views/index.jinja new file mode 100644 index 00000000..6679c464 --- /dev/null +++ b/server/diypedals/web/templates/views/index.jinja @@ -0,0 +1,5 @@ +{% extends 'layouts/default.jinja' %} + +{% block main %} +

diypedals.fm

+{% endblock main %} diff --git a/server/diypedals/web/templating.py b/server/diypedals/web/templating.py new file mode 100644 index 00000000..b9f9c7f9 --- /dev/null +++ b/server/diypedals/web/templating.py @@ -0,0 +1,33 @@ +import datetime as dt +from typing import Any + +import jinja2 +from starlette.templating import Jinja2Templates + +from .. import settings +from ..infrastructure.urls import get_absolute_path, to_production_url +from .reload import HotReload + + +class Templates(Jinja2Templates): + def __init__(self, hotreload: HotReload) -> None: + super().__init__(directory=str(settings.TEMPLATES_DIR)) + + def _absolute_url(obj: Any) -> str: + return to_production_url(get_absolute_path(obj)) + + self.env.globals["now"] = dt.datetime.now + self.env.globals["settings"] = settings + self.env.globals["hotreload"] = hotreload + self.env.filters["absolute_url"] = _absolute_url + self.env.filters["dateformat"] = _dateformat + self.env.filters["isoformat"] = lambda value: value.isoformat() + + def from_string(self, source: str) -> jinja2.Template: + return self.env.from_string(source) + + +def _dateformat(value: dt.date | str) -> str: + if isinstance(value, str): + value = dt.date.fromisoformat(value) + return value.strftime("%b %d, %Y") diff --git a/server/diypedals/web/views.py b/server/diypedals/web/views.py new file mode 100644 index 00000000..945b3503 --- /dev/null +++ b/server/diypedals/web/views.py @@ -0,0 +1,74 @@ +from starlette.endpoints import HTTPEndpoint +from starlette.requests import Request +from starlette.responses import Response + +from .. import settings +from ..di import resolve +from ..domain.repositories import BuildReportRepository +from .templating import Templates + + +class Home(HTTPEndpoint): + async def get(self, request: Request) -> Response: + templates = resolve(Templates) + + return templates.TemplateResponse(request, "views/index.jinja") + + +class BuildReportList(HTTPEndpoint): + async def get(self, request: Request) -> Response: + templates = resolve(Templates) + build_report_repository = resolve(BuildReportRepository) + + build_reports = await build_report_repository.find_all() + categories = sorted(await build_report_repository.get_unique_categories()) + + context = { + "build_reports": build_reports, + "categories": categories, + } + + return templates.TemplateResponse( + request, "views/build_reports/list.jinja", context + ) + + +class BuildReportDetail(HTTPEndpoint): + async def get(self, request: Request) -> Response: + templates = resolve(Templates) + build_report_repository = resolve(BuildReportRepository) + + slug = request.path_params["slug"] + + build_report = await build_report_repository.find_one(slug=slug) + + context: dict = { + "build_report": build_report, + "show_photos": request.query_params.get("photos") == "true", + } + + return templates.TemplateResponse( + request, "views/build_reports/detail.jinja", context + ) + + +class BuildReportCategoryDetail(HTTPEndpoint): + async def get(self, request: Request) -> Response: + templates = resolve(Templates) + build_report_repository = resolve(BuildReportRepository) + + category = request.path_params["category"] + + build_reports = await build_report_repository.find_all(category=category) + categories = sorted(await build_report_repository.get_unique_categories()) + + context = { + "category": category, + "build_reports": build_reports, + "categories": categories, + "current_category": category, + } + + return templates.TemplateResponse( + request, "views/build_reports/categories/detail.jinja", context + ) diff --git a/server/main.py b/server/main.py index d057555d..dd724af6 100644 --- a/server/main.py +++ b/server/main.py @@ -1,21 +1,34 @@ +import copy + import uvicorn import uvicorn.supervisors +from uvicorn.config import LOGGING_CONFIG from . import settings from .di import bootstrap +from .diypedals.di import bootstrap as diypedals_bootstrap from .web.app import create_app bootstrap() +diypedals_bootstrap() app = create_app() def get_config() -> uvicorn.Config: + log_config = copy.deepcopy(LOGGING_CONFIG) + + log_config["loggers"]["httpx"] = log_config["loggers"]["httpcore"] = { + "handlers": ["default"], + "level": "INFO", + } + config: dict = dict( host=settings.HOST, port=settings.PORT, use_colors=True, reload=settings.DEBUG, + log_config=log_config, ) return uvicorn.Config("server.main:app", **config) diff --git a/server/web/app.py b/server/web/app.py index a904b92f..0446848b 100644 --- a/server/web/app.py +++ b/server/web/app.py @@ -6,6 +6,7 @@ from .. import settings from ..di import resolve +from ..diypedals.web.app import create_app as create_diypedals from ..infrastructure.database import InMemoryDatabase from . import views from .middleware import middleware @@ -17,7 +18,9 @@ def create_app() -> ASGIApp: db = resolve(InMemoryDatabase) hotreload = resolve(HotReload) - routes = get_routes() + diypedals = create_diypedals() + + routes = get_routes(diypedals.routes) @asynccontextmanager async def lifespan(app: Starlette) -> AsyncIterator[None]: @@ -26,7 +29,8 @@ async def lifespan(app: Starlette) -> AsyncIterator[None]: if settings.DEBUG: # pragma: no cover await hotreload.startup() - yield + async with diypedals.lifespan(): + yield if settings.DEBUG: # pragma: no cover await hotreload.shutdown() diff --git a/server/web/routes.py b/server/web/routes.py index 15cfa794..07732d7d 100644 --- a/server/web/routes.py +++ b/server/web/routes.py @@ -1,4 +1,5 @@ -from starlette.routing import BaseRoute, Host, Mount, Route, WebSocketRoute +from starlette.routing import BaseRoute, Host, Mount, Route, Router, WebSocketRoute +from starlette.types import ASGIApp from .. import settings from ..di import resolve @@ -9,7 +10,7 @@ from .statics import CachedStaticFiles -def get_routes() -> list[BaseRoute]: +def get_routes(diypedals_routes: list[BaseRoute]) -> list[BaseRoute]: static = CachedStaticFiles( directory=str(settings.STATIC_DIR), max_age=7 * 86400, # 7 days @@ -33,6 +34,7 @@ def get_routes() -> list[BaseRoute]: app=legacy.DomainRedirect("florimond.dev", root_path="/blog"), name="legacy:blog_dot_dev", ), + Mount("/diypedals", routes=diypedals_routes, name="diypedals"), Route("/error/", views.error), Route("/blog/", views.legacy_blog_home, name="legacy:blog_home"), Mount(settings.STATIC_ROOT, static, name="static"), diff --git a/tools/pybuild.py b/tools/pybuild.py index 75057042..4748d7f6 100755 --- a/tools/pybuild.py +++ b/tools/pybuild.py @@ -49,7 +49,7 @@ def _replace_imports(path: Path) -> str: lines = path.read_text().splitlines(keepends=True) - for lineno, line, full_path in imports: + for lineno, _, full_path in imports: content = _replace_imports(full_path) lines[lineno] = content @@ -77,12 +77,14 @@ def main(input_files: list[Path], outdir: Path, watch: bool = False) -> None: if watch: watched_files = set(input_files) + files_to_analyze = set(input_files) - for path in input_files: + while files_to_analyze: + path = files_to_analyze.pop() for _, _, full_path in _get_imports(path): watched_files.add(full_path) + files_to_analyze.add(full_path) - print("Watching for changes...") for changes in watchfiles.watch(*watched_files, raise_interrupt=False): changed_files = [changed_path for _, changed_path in changes] print(f"--> Change detected in {', '.join(changed_files)}")