From 8f993245d78ac0fa0edadaeb8306e0bed4e97318 Mon Sep 17 00:00:00 2001 From: remdui Date: Sun, 15 Mar 2026 19:18:44 +0100 Subject: [PATCH 1/2] Add theme loader --- .env.example | 1 + .github/CODEOWNERS | 2 +- README.md | 4 +- apps/panel/forms.py | 17 + apps/panel/views.py | 162 +++++++- apps/web/context_processors.py | 10 + apps/web/urls.py | 1 + apps/web/views.py | 92 +++++ compose.yaml | 4 + infra/docker/app.Dockerfile | 5 +- infra/docker/entrypoint.sh | 3 + mylonite/core/site_config_store.py | 49 +++ mylonite/core/theme_loader.py | 388 +++++++++++++++++- mylonite/core/toml_utils.py | 74 ++++ mylonite/settings.py | 5 +- templates/base.html | 3 +- templates/panel/auth_base.html | 3 +- templates/panel/base.html | 3 +- templates/panel/settings.html | 174 +++++--- tests/apps/panel/test_views.py | 152 +++++++ .../apps/web/test_architecture_extensions.py | 25 +- tests/apps/web/test_views.py | 126 ++++++ tests/mylonite/core/test_core_modules.py | 57 ++- tests/mylonite/test_urls.py | 6 + themes/README.md | 51 +++ themes/default/README.md | 34 ++ .../default/static}/css/site.css | 0 themes/default/theme.toml | 4 + 28 files changed, 1358 insertions(+), 97 deletions(-) create mode 100644 apps/web/context_processors.py create mode 100644 mylonite/core/site_config_store.py create mode 100644 mylonite/core/toml_utils.py create mode 100644 themes/README.md create mode 100644 themes/default/README.md rename {static => themes/default/static}/css/site.css (100%) create mode 100644 themes/default/theme.toml diff --git a/.env.example b/.env.example index 34a5d0c..d6008ae 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ MYLONITE_CONFIG_DIR=./runtime/config MYLONITE_DATA_DIR=./runtime/data MYLONITE_CONTENT_DIR=./content +MYLONITE_THEMES_DIR=./themes MYLONITE_BIND_PORT=8000 # Linux-friendly defaults for bind-mounted file ownership. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0b278d2..026ab79 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,7 +6,7 @@ /mylonite/ @remdui /templates/ @remdui /content/ @remdui -/static/ @remdui +/themes/ @remdui /tests/ @remdui # Infrastructure and automation diff --git a/README.md b/README.md index c02bc5d..3ef3b16 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ Optional overrides: - If you want to customize bind paths, port, or runtime UID/GID mapping, copy `.env.example` to `.env` and adjust values. - On Linux, leave `MYLONITE_PUID/MYLONITE_PGID` at `1000` unless your user/group IDs are different. +- Theme folders are bind-mounted from `./themes` by default (`MYLONITE_THEMES_DIR`). #### Initialize editable local content @@ -210,7 +211,7 @@ Mylonite/ runtime/config/ Local runtime configs runtime/data/ Persistent runtime states scripts/ Helper scripts - static/ Static source files + themes/ Theme folders and static theme assets templates/ Django website templates ``` @@ -225,4 +226,3 @@ Mylonite/ ## License Licensed under the GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later). See the `LICENSE` file for details. - diff --git a/apps/panel/forms.py b/apps/panel/forms.py index e549573..56a6f97 100644 --- a/apps/panel/forms.py +++ b/apps/panel/forms.py @@ -165,3 +165,20 @@ def __init__(self, user, *args, **kwargs) -> None: "placeholder": "Repeat new password", } ) + + +class ThemeSelectionForm(StyledFormMixin, forms.Form): + theme_name = forms.ChoiceField( + label="Theme", + choices=(), + ) + + def __init__( + self, + *args, + theme_choices: list[tuple[str, str]] | None = None, + **kwargs, + ) -> None: + super().__init__(*args, **kwargs) + self.fields["theme_name"].choices = theme_choices or [] + self._apply_common_widget_attrs() diff --git a/apps/panel/views.py b/apps/panel/views.py index 6b91751..fabd1bb 100644 --- a/apps/panel/views.py +++ b/apps/panel/views.py @@ -1,6 +1,8 @@ from django.contrib import messages from django.contrib.auth import login +from django.contrib.auth import update_session_auth_hash from django.contrib.auth import views as auth_views +from django.conf import settings from django.core.exceptions import PermissionDenied from django.db import IntegrityError, transaction from django.http import HttpRequest, HttpResponse @@ -10,7 +12,20 @@ from django.views import View from django.views.generic import FormView, TemplateView -from .forms import OwnerSetupForm, PanelAuthenticationForm, PanelPasswordChangeForm +from pathlib import Path + +from mylonite.core.site_config_store import ( + load_site_config_payload, + write_site_config_payload, +) +from mylonite.core.theme_loader import ThemeResolver, load_active_theme_settings + +from .forms import ( + OwnerSetupForm, + PanelAuthenticationForm, + PanelPasswordChangeForm, + ThemeSelectionForm, +) from .models import SiteSetup from .services import ( InitialSetupAlreadyComplete, @@ -175,18 +190,141 @@ class PanelDashboardView(OwnerRequiredMixin, PanelContextMixin, TemplateView): ) -class PanelSettingsView( - OwnerRequiredMixin, - PanelContextMixin, - auth_views.PasswordChangeView, -): +class PanelSettingsView(OwnerRequiredMixin, PanelContextMixin, TemplateView): template_name = "panel/settings.html" - form_class = PanelPasswordChangeForm - success_url = reverse_lazy("panel:settings") panel_section = "settings" panel_heading = "Settings" - panel_description = "Update your owner account credentials." + panel_description = "Update your owner account credentials and theme." + + @property + def _content_root(self) -> Path: + return Path(settings.MYLONITE_CONTENT_ROOT) + + @property + def _themes_root(self) -> Path: + return Path(settings.MYLONITE_THEMES_ROOT) + + def _load_theme_context(self): + resolver = ThemeResolver(self._themes_root) + site_theme_settings = load_active_theme_settings(content_root=self._content_root) + discovered_themes = resolver.discover_themes() + resolved_theme = resolver.resolve( + site_theme_settings, + themes=discovered_themes, + ) + selectable_themes = resolver.selectable_themes( + custom_theme_allowed=site_theme_settings.custom_theme_allowed, + themes=discovered_themes, + ) - def form_valid(self, form): - messages.success(self.request, "Password updated successfully.") - return super().form_valid(form) + return { + "site_theme_settings": site_theme_settings, + "resolved_theme": resolved_theme, + "selectable_themes": selectable_themes, + "theme_choices": [ + ( + theme.theme_id, + f"{theme.metadata.name} ({theme.theme_id})", + ) + for theme in selectable_themes + ], + } + + def _get_password_form(self, data=None): + return PanelPasswordChangeForm(self.request.user, data=data) + + def _get_theme_form(self, *, theme_choices, data=None, initial_theme_id="default"): + return ThemeSelectionForm( + data=data, + theme_choices=theme_choices, + initial={"theme_name": initial_theme_id}, + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + theme_context = self._load_theme_context() + + password_form = kwargs.get("password_form") or self._get_password_form() + theme_form = kwargs.get("theme_form") or self._get_theme_form( + theme_choices=theme_context["theme_choices"], + initial_theme_id=theme_context["resolved_theme"].active_theme.theme_id, + ) + + context["password_form"] = password_form + context["theme_form"] = theme_form + context["theme_options"] = theme_context["selectable_themes"] + context["active_theme_id"] = theme_context["resolved_theme"].active_theme.theme_id + context["custom_theme_allowed"] = ( + theme_context["site_theme_settings"].custom_theme_allowed + ) + context["missing_theme_files"] = ( + theme_context["resolved_theme"].missing_required_static_files + ) + return context + + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + action = request.POST.get("settings_action", "").strip().lower() + if action == "password": + return self._handle_password_update() + if action == "theme": + return self._handle_theme_update() + return redirect("panel:settings") + + def _handle_password_update(self) -> HttpResponse: + password_form = self._get_password_form(data=self.request.POST) + theme_context = self._load_theme_context() + theme_form = self._get_theme_form( + theme_choices=theme_context["theme_choices"], + initial_theme_id=theme_context["resolved_theme"].active_theme.theme_id, + ) + + if password_form.is_valid(): + user = password_form.save() + update_session_auth_hash(self.request, user) + messages.success(self.request, "Password updated successfully.") + return redirect("panel:settings") + + context = self.get_context_data( + password_form=password_form, + theme_form=theme_form, + ) + return self.render_to_response(context) + + def _handle_theme_update(self) -> HttpResponse: + theme_context = self._load_theme_context() + password_form = self._get_password_form() + theme_form = self._get_theme_form( + data=self.request.POST, + theme_choices=theme_context["theme_choices"], + initial_theme_id=theme_context["resolved_theme"].active_theme.theme_id, + ) + + if theme_form.is_valid(): + selected_theme_id = theme_form.cleaned_data["theme_name"] + payload = load_site_config_payload(self._content_root) + theme_payload = payload.setdefault("theme", {}) + theme_payload["name"] = selected_theme_id + write_site_config_payload(self._content_root, payload) + + selected_theme = next( + ( + theme + for theme in theme_context["selectable_themes"] + if theme.theme_id == selected_theme_id + ), + None, + ) + display_name = ( + selected_theme.metadata.name if selected_theme else selected_theme_id + ) + messages.success( + self.request, + f"Theme updated to {display_name}.", + ) + return redirect("panel:settings") + + context = self.get_context_data( + password_form=password_form, + theme_form=theme_form, + ) + return self.render_to_response(context) diff --git a/apps/web/context_processors.py b/apps/web/context_processors.py new file mode 100644 index 0000000..fdc7616 --- /dev/null +++ b/apps/web/context_processors.py @@ -0,0 +1,10 @@ +from django.urls import reverse + + +def theme_assets(_request): + return { + "theme_css_url": reverse( + "theme_static", + kwargs={"asset_path": "css/site.css"}, + ) + } diff --git a/apps/web/urls.py b/apps/web/urls.py index f6d0666..a2f4c05 100644 --- a/apps/web/urls.py +++ b/apps/web/urls.py @@ -3,6 +3,7 @@ from . import views urlpatterns = [ + path("theme-static/", views.theme_static, name="theme_static"), path("health/", views.health, name="health"), path("", views.home, name="home"), ] diff --git a/apps/web/views.py b/apps/web/views.py index 6cc7d68..d75c1e7 100644 --- a/apps/web/views.py +++ b/apps/web/views.py @@ -1,6 +1,20 @@ +import mimetypes +from pathlib import Path + +from django.conf import settings +from django.http import FileResponse +from django.http import Http404 from django.http import JsonResponse +from django.http import HttpResponse from django.views.generic import TemplateView, View +from mylonite.core.theme_loader import ( + ResolvedTheme, + ThemeResolver, + load_active_theme_settings, + normalize_theme_asset_path, +) + from .page_contexts import HomePageContextBuilder, WebPageContextFactory @@ -26,5 +40,83 @@ class HomePageView(PageContextTemplateView): page_name = HomePageContextBuilder.page_name +class ThemeStaticView(View): + primary_css_asset_path = "css/site.css" + + def _build_css_response( + self, + *, + resolved_theme: ResolvedTheme, + normalized_path: str, + active_css_path: Path, + ) -> HttpResponse | None: + if normalized_path != self.primary_css_asset_path: + return None + + if ( + resolved_theme.active_theme.theme_id + == resolved_theme.default_theme.theme_id + ): + return None + + default_css_path = resolved_theme.default_theme.static_dir / normalized_path + if not default_css_path.is_file(): + return None + + try: + default_css = default_css_path.read_text(encoding="utf-8") + active_css = active_css_path.read_text(encoding="utf-8") + except OSError: + return None + + merged_css = ( + f"{default_css}\n\n" + f"/* Theme overrides ({resolved_theme.active_theme.theme_id}) */\n" + f"{active_css}\n" + ) + response = HttpResponse(merged_css, content_type="text/css; charset=utf-8") + response["Cache-Control"] = "no-cache" + return response + + def get(self, request, asset_path: str): + normalized_path = normalize_theme_asset_path(asset_path) + if not normalized_path: + raise Http404("Invalid theme asset path.") + + resolver = ThemeResolver(Path(settings.MYLONITE_THEMES_ROOT)) + discovered_themes = resolver.discover_themes() + theme_settings = load_active_theme_settings( + content_root=Path(settings.MYLONITE_CONTENT_ROOT) + ) + resolved_theme = resolver.resolve(theme_settings, themes=discovered_themes) + resolved_asset = resolver.resolve_static_asset( + resolved_theme, + asset_path=normalized_path, + ) + if resolved_asset is None: + raise Http404("Theme asset not found.") + + if ( + normalized_path.endswith(".css") + and not resolved_asset.from_fallback + ): + css_response = self._build_css_response( + resolved_theme=resolved_theme, + normalized_path=normalized_path, + active_css_path=resolved_asset.resolved_path, + ) + if css_response is not None: + return css_response + + content_type, _ = mimetypes.guess_type(resolved_asset.resolved_path.as_posix()) + response = FileResponse( + resolved_asset.resolved_path.open("rb"), + content_type=content_type or "application/octet-stream", + ) + response["Cache-Control"] = "no-cache" + return response + + health = HealthView.as_view() home = HomePageView.as_view() +theme_static = ThemeStaticView.as_view() diff --git a/compose.yaml b/compose.yaml index 01bf1a5..f2f78f9 100644 --- a/compose.yaml +++ b/compose.yaml @@ -9,6 +9,7 @@ services: MYLONITE_DATA_ROOT: /data MYLONITE_DB_PATH: /data/db/mylonite.sqlite3 MYLONITE_CONTENT_ROOT: /content + MYLONITE_THEMES_ROOT: /themes volumes: - type: bind source: ${MYLONITE_CONFIG_DIR:-./runtime/config} @@ -19,6 +20,9 @@ services: - type: bind source: ${MYLONITE_CONTENT_DIR:-./content} target: /content + - type: bind + source: ${MYLONITE_THEMES_DIR:-./themes} + target: /themes user: "${MYLONITE_PUID:-1000}:${MYLONITE_PGID:-1000}" restart: unless-stopped init: true diff --git a/infra/docker/app.Dockerfile b/infra/docker/app.Dockerfile index 3fdff63..40ff4c6 100644 --- a/infra/docker/app.Dockerfile +++ b/infra/docker/app.Dockerfile @@ -16,7 +16,6 @@ COPY manage.py /app/ COPY mylonite /app/mylonite COPY apps /app/apps COPY templates /app/templates -COPY static /app/static COPY infra/docker/entrypoint.sh /usr/local/bin/mylonite-entrypoint RUN find /app -type d -exec chmod 0755 {} \; \ @@ -24,8 +23,8 @@ RUN find /app -type d -exec chmod 0755 {} \; \ && chmod 0755 /usr/local/bin/mylonite-entrypoint \ && python -m pip install --upgrade pip \ && python -m pip install . \ - && mkdir -p /config /data /content \ - && chmod 0755 /config /data /content + && mkdir -p /config /data /content /themes \ + && chmod 0755 /config /data /content /themes EXPOSE 8000 diff --git a/infra/docker/entrypoint.sh b/infra/docker/entrypoint.sh index 0e3398e..f978b5b 100644 --- a/infra/docker/entrypoint.sh +++ b/infra/docker/entrypoint.sh @@ -4,6 +4,7 @@ set -eu CONFIG_ROOT="${MYLONITE_CONFIG_ROOT:-/config}" DATA_ROOT="${MYLONITE_DATA_ROOT:-/data}" CONTENT_ROOT="${MYLONITE_CONTENT_ROOT:-/content}" +THEMES_ROOT="${MYLONITE_THEMES_ROOT:-/app/themes}" DB_PATH="${MYLONITE_DB_PATH:-$DATA_ROOT/db/mylonite.sqlite3}" ensure_dir() { @@ -28,6 +29,7 @@ umask 0022 ensure_dir "$CONFIG_ROOT" ensure_dir "$DATA_ROOT" ensure_dir "$(dirname "$DB_PATH")" +# Django collectstatic output (admin/staticfiles pipeline), not theme source files. ensure_dir "$DATA_ROOT/static" ensure_dir "$DATA_ROOT/media" @@ -41,6 +43,7 @@ chmod 0644 "$DB_PATH" || true export MYLONITE_CONFIG_ROOT="$CONFIG_ROOT" export MYLONITE_DATA_ROOT="$DATA_ROOT" export MYLONITE_CONTENT_ROOT="$CONTENT_ROOT" +export MYLONITE_THEMES_ROOT="$THEMES_ROOT" export MYLONITE_DB_PATH="$DB_PATH" python - <<'PY' diff --git a/mylonite/core/site_config_store.py b/mylonite/core/site_config_store.py new file mode 100644 index 0000000..81d430a --- /dev/null +++ b/mylonite/core/site_config_store.py @@ -0,0 +1,49 @@ +"""Filesystem utilities for reading and writing `content/config/site.toml`.""" + +from __future__ import annotations + +import tomllib +from copy import deepcopy +from pathlib import Path +from typing import Any + +from .content_schema import SITE_CONFIG_SCHEMA, schema_defaults +from .toml_utils import render_toml + + +def _read_toml(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + with path.open("rb") as handle: + data = tomllib.load(handle) + return data if isinstance(data, dict) else {} + + +def _deep_merge(base: dict[str, Any], updates: dict[str, Any]) -> dict[str, Any]: + merged = deepcopy(base) + for key, value in updates.items(): + if isinstance(merged.get(key), dict) and isinstance(value, dict): + merged[key] = _deep_merge(merged[key], value) + continue + merged[key] = value + return merged + + +def site_config_path(content_root: Path) -> Path: + return content_root / "config" / "site.toml" + + +def load_site_config_payload(content_root: Path) -> dict[str, Any]: + path = site_config_path(content_root) + payload = _read_toml(path) + if not payload: + payload = _read_toml(path.with_name(f"{path.name}.example")) + + return _deep_merge(schema_defaults(SITE_CONFIG_SCHEMA), payload) + + +def write_site_config_payload(content_root: Path, payload: dict[str, Any]) -> None: + path = site_config_path(content_root) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(render_toml(payload), encoding="utf-8") + diff --git a/mylonite/core/theme_loader.py b/mylonite/core/theme_loader.py index f7e17fb..ef735b5 100644 --- a/mylonite/core/theme_loader.py +++ b/mylonite/core/theme_loader.py @@ -1,28 +1,394 @@ +from __future__ import annotations + +import logging +import re +import tomllib from dataclasses import dataclass -from pathlib import Path +from pathlib import Path, PurePosixPath +from typing import ClassVar from .content_types import ThemeSettings +from .site_config_store import load_site_config_payload + +logger = logging.getLogger(__name__) + +THEME_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]*$") +THEME_METADATA_FILENAME = "theme.toml" +DEFAULT_THEME_ID = "default" +REQUIRED_THEME_METADATA_FIELDS = ("name", "description", "version") + + +@dataclass(frozen=True) +class ThemeMetadata: + name: str + description: str + version: str @dataclass(frozen=True) -class ThemePaths: - template_dir: Path +class ThemeDefinition: + theme_id: str + root_dir: Path static_dir: Path + metadata: ThemeMetadata + is_default: bool = False + + +@dataclass(frozen=True) +class ThemeAsset: + requested_path: str + resolved_path: Path + from_fallback: bool + + +@dataclass(frozen=True) +class ResolvedTheme: + requested_theme_id: str + active_theme: ThemeDefinition + default_theme: ThemeDefinition + missing_required_static_files: tuple[str, ...] + custom_theme_allowed: bool + + +def _parse_boolean(value: object, default: bool) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"true", "1", "yes", "on"}: + return True + if normalized in {"false", "0", "no", "off"}: + return False + return default + + +def normalize_theme_asset_path(asset_path: str) -> str | None: + candidate = asset_path.strip().lstrip("/") + if not candidate: + return None + + normalized = PurePosixPath(candidate) + if normalized.is_absolute() or ".." in normalized.parts: + return None + + path = PurePosixPath(*(part for part in normalized.parts if part not in {"", "."})) + return path.as_posix() if path.parts else None + + +def load_active_theme_settings(*, content_root: Path) -> ThemeSettings: + payload = load_site_config_payload(content_root) + raw_theme = payload.get("theme", {}) + theme_data = raw_theme if isinstance(raw_theme, dict) else {} + + raw_name = theme_data.get("name", DEFAULT_THEME_ID) + name = ( + raw_name.strip() + if isinstance(raw_name, str) and raw_name.strip() + else DEFAULT_THEME_ID + ) + + custom_theme_allowed = _parse_boolean( + theme_data.get("custom_theme_allowed", True), + default=True, + ) + + return ThemeSettings( + name=name, + custom_theme_allowed=custom_theme_allowed, + ) class ThemeResolver: - """Resolves active theme directories; supports user-created themes.""" + """Discovers themes and resolves static assets with default-theme fallback.""" + + _discovery_cache: ClassVar[ + dict[str, tuple[tuple[tuple[str, int, int], ...], tuple[ThemeDefinition, ...]]] + ] = {} + _warned_selection_fallbacks: ClassVar[set[str]] = set() + _warned_missing_assets: ClassVar[set[str]] = set() + _warned_invalid_theme_definitions: ClassVar[set[str]] = set() def __init__(self, themes_root: Path): self.themes_root = themes_root - def resolve(self, settings: ThemeSettings) -> ThemePaths: - requested = self.themes_root / settings.name - default = self.themes_root / "default" + def discover_themes(self) -> list[ThemeDefinition]: + if not self.themes_root.exists(): + return [] + + signature = self._build_discovery_signature() + cache_key = str(self.themes_root.resolve()) + cached_entry = self._discovery_cache.get(cache_key) + if cached_entry is not None and cached_entry[0] == signature: + return list(cached_entry[1]) + + themes: list[ThemeDefinition] = [] + for child in sorted(self.themes_root.iterdir(), key=lambda path: path.name): + if not child.is_dir(): + continue + + theme = self._parse_theme(child) + if theme is not None: + themes.append(theme) + + self._discovery_cache[cache_key] = (signature, tuple(themes)) + return themes + + def selectable_themes( + self, + *, + custom_theme_allowed: bool, + themes: list[ThemeDefinition] | None = None, + ) -> list[ThemeDefinition]: + available_themes = themes or self.discover_themes() + if custom_theme_allowed: + return available_themes + return [ + theme for theme in available_themes if theme.theme_id == DEFAULT_THEME_ID + ] + + def resolve( + self, + settings: ThemeSettings, + *, + themes: list[ThemeDefinition] | None = None, + ) -> ResolvedTheme: + available_themes = themes or self.discover_themes() + theme_map = {theme.theme_id: theme for theme in available_themes} + default_theme = theme_map.get(DEFAULT_THEME_ID) + if default_theme is None: + raise RuntimeError( + f"Missing required default theme at {self.themes_root / DEFAULT_THEME_ID}." + ) + + requested_theme_id = ( + settings.name.strip() + if isinstance(settings.name, str) and settings.name.strip() + else DEFAULT_THEME_ID + ) + requested_theme = theme_map.get(requested_theme_id) + + should_force_default = ( + requested_theme_id != DEFAULT_THEME_ID and not settings.custom_theme_allowed + ) + if requested_theme is None or should_force_default: + fallback_reason = ( + "custom themes are disabled" + if should_force_default + else "theme folder is invalid or missing" + ) + warning_key = f"{requested_theme_id}:{fallback_reason}" + if requested_theme_id != DEFAULT_THEME_ID: + self._warn_selection_fallback_once( + warning_key, + requested_theme_id=requested_theme_id, + fallback_reason=fallback_reason, + ) + requested_theme = default_theme + + missing_files = () + if requested_theme.theme_id != default_theme.theme_id: + missing_files = self._missing_required_static_files( + active_theme=requested_theme, + default_theme=default_theme, + ) + self._warn_missing_assets_once(requested_theme.theme_id, missing_files) + + return ResolvedTheme( + requested_theme_id=requested_theme_id, + active_theme=requested_theme, + default_theme=default_theme, + missing_required_static_files=missing_files, + custom_theme_allowed=settings.custom_theme_allowed, + ) + + def resolve_static_asset( + self, + resolved_theme: ResolvedTheme, + *, + asset_path: str, + ) -> ThemeAsset | None: + normalized_path = normalize_theme_asset_path(asset_path) + if not normalized_path: + return None + + active_file = resolved_theme.active_theme.static_dir / normalized_path + if active_file.is_file(): + return ThemeAsset( + requested_path=normalized_path, + resolved_path=active_file, + from_fallback=False, + ) - active = requested if requested.exists() else default + fallback_file = resolved_theme.default_theme.static_dir / normalized_path + if fallback_file.is_file(): + return ThemeAsset( + requested_path=normalized_path, + resolved_path=fallback_file, + from_fallback=True, + ) + + return None + + def _parse_theme(self, theme_dir: Path) -> ThemeDefinition | None: + theme_id = theme_dir.name + if not THEME_ID_PATTERN.fullmatch(theme_id): + self._warn_invalid_theme_once( + theme_id, + "invalid-folder-name", + "Ignoring theme folder '%s': folder name must match %s.", + theme_id, + THEME_ID_PATTERN.pattern, + ) + return None + + static_dir = theme_dir / "static" + if not static_dir.is_dir(): + self._warn_invalid_theme_once( + theme_id, + "missing-static-directory", + "Ignoring theme '%s': missing required 'static/' directory.", + theme_id, + ) + return None + + metadata_path = theme_dir / THEME_METADATA_FILENAME + if not metadata_path.is_file(): + self._warn_invalid_theme_once( + theme_id, + "missing-theme-metadata", + "Ignoring theme '%s': missing required '%s'.", + theme_id, + THEME_METADATA_FILENAME, + ) + return None + + try: + with metadata_path.open("rb") as handle: + raw_metadata = tomllib.load(handle) + except (tomllib.TOMLDecodeError, OSError): + self._warn_invalid_theme_once( + theme_id, + "invalid-theme-metadata", + "Ignoring theme '%s': invalid %s file.", + theme_id, + THEME_METADATA_FILENAME, + ) + return None + + values: dict[str, str] = {} + for field in REQUIRED_THEME_METADATA_FIELDS: + value = raw_metadata.get(field) + if not isinstance(value, str) or not value.strip(): + self._warn_invalid_theme_once( + theme_id, + f"invalid-theme-metadata-field-{field}", + "Ignoring theme '%s': %s must define a non-empty string '%s'.", + theme_id, + THEME_METADATA_FILENAME, + field, + ) + return None + values[field] = value.strip() + + metadata = ThemeMetadata( + name=values["name"], + description=values["description"], + version=values["version"], + ) - return ThemePaths( - template_dir=active / "templates", - static_dir=active / "static", + return ThemeDefinition( + theme_id=theme_id, + root_dir=theme_dir, + static_dir=static_dir, + metadata=metadata, + is_default=(theme_id == DEFAULT_THEME_ID), ) + + def _missing_required_static_files( + self, + *, + active_theme: ThemeDefinition, + default_theme: ThemeDefinition, + ) -> tuple[str, ...]: + required_files = self._list_static_files(default_theme.static_dir) + active_files = self._list_static_files(active_theme.static_dir) + missing = sorted(required_files - active_files) + return tuple(missing) + + def _list_static_files(self, static_dir: Path) -> set[str]: + if not static_dir.exists(): + return set() + + return { + path.relative_to(static_dir).as_posix() + for path in static_dir.rglob("*") + if path.is_file() + } + + def _warn_selection_fallback_once( + self, + warning_key: str, + *, + requested_theme_id: str, + fallback_reason: str, + ) -> None: + if warning_key in self._warned_selection_fallbacks: + return + + self._warned_selection_fallbacks.add(warning_key) + logger.warning( + "Requested theme '%s' cannot be activated (%s). Falling back to '%s'.", + requested_theme_id, + fallback_reason, + DEFAULT_THEME_ID, + ) + + def _warn_missing_assets_once( + self, theme_id: str, missing_files: tuple[str, ...] + ) -> None: + if not missing_files: + return + + warning_key = f"{theme_id}:{','.join(missing_files)}" + if warning_key in self._warned_missing_assets: + return + + self._warned_missing_assets.add(warning_key) + logger.warning( + "Theme '%s' is missing %d required static assets. " + "Missing files: %s. " + "The default theme will be used as fallback for those assets.", + theme_id, + len(missing_files), + ", ".join(missing_files), + ) + + def _build_discovery_signature(self) -> tuple[tuple[str, int, int], ...]: + entries: list[tuple[str, int, int]] = [] + for child in sorted(self.themes_root.iterdir(), key=lambda path: path.name): + if not child.is_dir(): + continue + + metadata_path = child / THEME_METADATA_FILENAME + static_dir = child / "static" + metadata_mtime_ns = ( + int(metadata_path.stat().st_mtime_ns) if metadata_path.exists() else -1 + ) + static_mtime_ns = int(static_dir.stat().st_mtime_ns) if static_dir.exists() else -1 + entries.append((child.name, metadata_mtime_ns, static_mtime_ns)) + + return tuple(entries) + + def _warn_invalid_theme_once( + self, + theme_id: str, + reason_key: str, + message: str, + *args: object, + ) -> None: + warning_key = f"{self.themes_root}:{theme_id}:{reason_key}" + if warning_key in self._warned_invalid_theme_definitions: + return + + self._warned_invalid_theme_definitions.add(warning_key) + logger.warning(message, *args) diff --git a/mylonite/core/toml_utils.py b/mylonite/core/toml_utils.py new file mode 100644 index 0000000..d0d5734 --- /dev/null +++ b/mylonite/core/toml_utils.py @@ -0,0 +1,74 @@ +"""Minimal TOML rendering helpers for deterministic local file output.""" + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class _TomlSection: + key_path: tuple[str, ...] + values: dict[str, Any] + + +def _format_toml_value(value: Any) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, str): + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + if isinstance(value, list): + rendered = ", ".join(_format_toml_value(item) for item in value) + return f"[{rendered}]" + raise TypeError(f"Unsupported TOML value type: {type(value)!r}") + + +def _walk_toml_sections( + payload: dict[str, Any], + *, + key_path: tuple[str, ...] = (), +) -> tuple[list[tuple[str, Any]], list[_TomlSection]]: + """Split payload into scalar fields and nested table sections recursively.""" + scalars: list[tuple[str, Any]] = [] + sections: list[_TomlSection] = [] + + for key, value in payload.items(): + if isinstance(value, dict): + section_scalars, nested_sections = _walk_toml_sections( + value, + key_path=(*key_path, key), + ) + sections.append( + _TomlSection( + key_path=(*key_path, key), + values={name: item for name, item in section_scalars}, + ) + ) + sections.extend(nested_sections) + continue + + scalars.append((key, value)) + + return scalars, sections + + +def render_toml(data: dict[str, Any]) -> str: + """Render deterministic TOML with nested table support.""" + scalar_fields, sections = _walk_toml_sections(data) + + lines: list[str] = [ + f"{key} = {_format_toml_value(value)}" for key, value in scalar_fields + ] + + for section in sections: + if lines: + lines.append("") + lines.append("[" + ".".join(section.key_path) + "]") + lines.extend( + f"{key} = {_format_toml_value(value)}" + for key, value in section.values.items() + ) + + return "\n".join(lines).rstrip() + "\n" + diff --git a/mylonite/settings.py b/mylonite/settings.py index ab0305d..a8e0d16 100644 --- a/mylonite/settings.py +++ b/mylonite/settings.py @@ -12,6 +12,7 @@ RUNTIME_ENV_FILE = CONFIG_ROOT / ".env" DATA_ROOT = Path(os.getenv("MYLONITE_DATA_ROOT", BASE_DIR / "runtime" / "data")) CONTENT_ROOT = Path(os.getenv("MYLONITE_CONTENT_ROOT", BASE_DIR / "content")) +THEMES_ROOT = Path(os.getenv("MYLONITE_THEMES_ROOT", BASE_DIR / "themes")) DB_PATH = Path(os.getenv("MYLONITE_DB_PATH", DATA_ROOT / "db" / "mylonite.sqlite3")) STATIC_ROOT_PATH = DATA_ROOT / "static" MEDIA_ROOT_PATH = DATA_ROOT / "media" @@ -165,6 +166,7 @@ def load_proxy_networks(values: list[str]) -> tuple: "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", + "apps.web.context_processors.theme_assets", ], }, }, @@ -202,7 +204,7 @@ def load_proxy_networks(values: list[str]) -> tuple: STATIC_URL = "/static/" STATIC_ROOT = STATIC_ROOT_PATH -STATICFILES_DIRS = [BASE_DIR / "static"] +STATICFILES_DIRS = [] MEDIA_URL = "/media/" MEDIA_ROOT = MEDIA_ROOT_PATH @@ -251,3 +253,4 @@ def load_proxy_networks(values: list[str]) -> tuple: MYLONITE_CONFIG_ROOT = CONFIG_ROOT MYLONITE_CONTENT_ROOT = CONTENT_ROOT MYLONITE_DATA_ROOT = DATA_ROOT +MYLONITE_THEMES_ROOT = THEMES_ROOT diff --git a/templates/base.html b/templates/base.html index 4418b55..9efa672 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,11 +1,10 @@ -{% load static %} {% if page_title %}{{ page_title }} · {% endif %}{{ portfolio_site.site_title }} - + {% if content_status.using_example_files %} diff --git a/templates/panel/auth_base.html b/templates/panel/auth_base.html index fddfeda..d672236 100644 --- a/templates/panel/auth_base.html +++ b/templates/panel/auth_base.html @@ -1,11 +1,10 @@ -{% load static %} {% if page_title %}{{ page_title }} · {% endif %}Mylonite Admin - +
diff --git a/templates/panel/base.html b/templates/panel/base.html index 1544891..4b4aa75 100644 --- a/templates/panel/base.html +++ b/templates/panel/base.html @@ -1,11 +1,10 @@ -{% load static %} {% if page_title %}{{ page_title }} · {% endif %}Mylonite Admin - +
diff --git a/templates/panel/settings.html b/templates/panel/settings.html index 5a61610..b70ca1d 100644 --- a/templates/panel/settings.html +++ b/templates/panel/settings.html @@ -1,67 +1,143 @@ {% extends "panel/base.html" %} {% block panel_content %} -
-
-

Change password

-

- Enter your current password and choose a new one. Password validation rules are enforced automatically. -

- -
- {% csrf_token %} - - {% if form.non_field_errors %} -
    - {% for error in form.non_field_errors %} -
  • {{ error }}
  • - {% endfor %} -
+
+
+
+

Theme

+

+ Themes are loaded from local folders in themes/. Only valid folders with + theme.toml metadata and a static/ directory are selectable. +

+ + {% if not custom_theme_allowed %} +

+ Custom themes are disabled in site configuration. Only the default theme can be selected. +

{% endif %} -
- {{ form.old_password.label_tag }} - {{ form.old_password }} - {% if form.old_password.errors %} -
    - {% for error in form.old_password.errors %} -
  • {{ error }}
  • - {% endfor %} -
- {% endif %} -
+ + {% csrf_token %} + -
- {{ form.new_password1.label_tag }} - {{ form.new_password1 }} - {% if form.new_password1.help_text %} -

{{ form.new_password1.help_text|safe }}

- {% endif %} - {% if form.new_password1.errors %} -
    - {% for error in form.new_password1.errors %} + {% if theme_form.non_field_errors %} +
      + {% for error in theme_form.non_field_errors %}
    • {{ error }}
    • {% endfor %}
    {% endif %} + +
    + {{ theme_form.theme_name.label_tag }} + {{ theme_form.theme_name }} + {% if theme_form.theme_name.errors %} +
      + {% for error in theme_form.theme_name.errors %} +
    • {{ error }}
    • + {% endfor %} +
    + {% endif %} +
    + +
    + +
    + + +

    + Active theme folder: {{ active_theme_id }} +

    + + {% if missing_theme_files %} +
    + The active theme is missing {{ missing_theme_files|length }} required static files. + Missing files fall back to the default theme automatically. +
    + {% endif %} + +
    + {% for theme in theme_options %} +
    +
    +

    + {{ theme.metadata.name }} + {% if theme.theme_id == active_theme_id %}(Active){% endif %} +

    +

    {{ theme.metadata.description }}

    +

    + Folder: {{ theme.theme_id }} · Version: {{ theme.metadata.version }} +

    +
    +
    + {% endfor %}
    +
+
+ +
+
+

Change password

+

+ Enter your current password and choose a new one. Password validation rules are enforced automatically. +

+ +
+ {% csrf_token %} + -
- {{ form.new_password2.label_tag }} - {{ form.new_password2 }} - {% if form.new_password2.errors %} -
    - {% for error in form.new_password2.errors %} + {% if password_form.non_field_errors %} +
      + {% for error in password_form.non_field_errors %}
    • {{ error }}
    • {% endfor %}
    {% endif %} -
-
- -
-
-
-
+
+ {{ password_form.old_password.label_tag }} + {{ password_form.old_password }} + {% if password_form.old_password.errors %} +
    + {% for error in password_form.old_password.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +
+ +
+ {{ password_form.new_password1.label_tag }} + {{ password_form.new_password1 }} + {% if password_form.new_password1.help_text %} +

{{ password_form.new_password1.help_text|safe }}

+ {% endif %} + {% if password_form.new_password1.errors %} +
    + {% for error in password_form.new_password1.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +
+ +
+ {{ password_form.new_password2.label_tag }} + {{ password_form.new_password2 }} + {% if password_form.new_password2.errors %} +
    + {% for error in password_form.new_password2.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +
+ +
+ +
+ +
+
+ {% endblock %} diff --git a/tests/apps/panel/test_views.py b/tests/apps/panel/test_views.py index 14c0b24..dbc7dfa 100644 --- a/tests/apps/panel/test_views.py +++ b/tests/apps/panel/test_views.py @@ -1,8 +1,15 @@ +from pathlib import Path +from tempfile import TemporaryDirectory + from django.contrib.auth import get_user_model from django.test import TestCase, override_settings from django.urls import reverse from apps.panel.models import SiteSetup +from mylonite.core.site_config_store import ( + load_site_config_payload, + write_site_config_payload, +) User = get_user_model() @@ -20,6 +27,26 @@ class PanelViewTests(TestCase): def setUp(self): self.owner_password = "StrongPassword123!" + @staticmethod + def _create_theme(theme_root: Path, theme_id: str) -> None: + root = theme_root / theme_id + (root / "static" / "css").mkdir(parents=True, exist_ok=True) + (root / "theme.toml").write_text( + '\n'.join( + [ + f'name = "{theme_id.title()}"', + f'description = "{theme_id} theme"', + 'version = "1.0.0"', + ] + ) + + "\n", + encoding="utf-8", + ) + (root / "static" / "css" / "site.css").write_text( + "body { color: #fff; }\n", + encoding="utf-8", + ) + def create_owner_and_initialize(self, username="owner"): owner = User.objects.create_user( username=username, password=self.owner_password @@ -153,3 +180,128 @@ def test_failed_login_throttles_after_configured_attempts(self): {"username": "owner", "password": "wrong-password"}, ) self.assertContains(third, "Too many sign-in attempts") + + def test_settings_page_lists_selectable_themes(self): + owner = self.create_owner_and_initialize() + self.client.force_login(owner) + + with TemporaryDirectory() as themes_tmp, TemporaryDirectory() as content_tmp: + themes_root = Path(themes_tmp) + content_root = Path(content_tmp) + self._create_theme(themes_root, "default") + self._create_theme(themes_root, "ocean") + write_site_config_payload( + content_root, + { + "site_title": "Site", + "site_url": "http://localhost:8000", + "owner_id": "identity.person.owner", + "footer_show_generated_by": True, + "footer_repository_url": "", + "hosting_mode": "local", + "public_domain": "", + "theme": {"name": "default", "custom_theme_allowed": True}, + "install": { + "setup_wizard_enabled": True, + "deferred_setup_allowed": True, + }, + }, + ) + + with self.settings( + MYLONITE_THEMES_ROOT=themes_root, + MYLONITE_CONTENT_ROOT=content_root, + ): + response = self.client.get(reverse("panel:settings")) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'value="ocean"') + self.assertContains(response, "Apply theme") + + def test_theme_settings_post_updates_site_config(self): + owner = self.create_owner_and_initialize() + self.client.force_login(owner) + + with TemporaryDirectory() as themes_tmp, TemporaryDirectory() as content_tmp: + themes_root = Path(themes_tmp) + content_root = Path(content_tmp) + self._create_theme(themes_root, "default") + self._create_theme(themes_root, "ocean") + write_site_config_payload( + content_root, + { + "site_title": "Site", + "site_url": "http://localhost:8000", + "owner_id": "identity.person.owner", + "footer_show_generated_by": True, + "footer_repository_url": "", + "hosting_mode": "local", + "public_domain": "", + "theme": {"name": "default", "custom_theme_allowed": True}, + "install": { + "setup_wizard_enabled": True, + "deferred_setup_allowed": True, + }, + }, + ) + + with self.settings( + MYLONITE_THEMES_ROOT=themes_root, + MYLONITE_CONTENT_ROOT=content_root, + ): + response = self.client.post( + reverse("panel:settings"), + { + "settings_action": "theme", + "theme_name": "ocean", + }, + ) + payload = load_site_config_payload(content_root) + + self.assertRedirects( + response, reverse("panel:settings"), fetch_redirect_response=False + ) + self.assertEqual(payload["theme"]["name"], "ocean") + + def test_theme_settings_rejects_invalid_theme_choice(self): + owner = self.create_owner_and_initialize() + self.client.force_login(owner) + + with TemporaryDirectory() as themes_tmp, TemporaryDirectory() as content_tmp: + themes_root = Path(themes_tmp) + content_root = Path(content_tmp) + self._create_theme(themes_root, "default") + write_site_config_payload( + content_root, + { + "site_title": "Site", + "site_url": "http://localhost:8000", + "owner_id": "identity.person.owner", + "footer_show_generated_by": True, + "footer_repository_url": "", + "hosting_mode": "local", + "public_domain": "", + "theme": {"name": "default", "custom_theme_allowed": True}, + "install": { + "setup_wizard_enabled": True, + "deferred_setup_allowed": True, + }, + }, + ) + + with self.settings( + MYLONITE_THEMES_ROOT=themes_root, + MYLONITE_CONTENT_ROOT=content_root, + ): + response = self.client.post( + reverse("panel:settings"), + { + "settings_action": "theme", + "theme_name": "nonexistent", + }, + ) + payload = load_site_config_payload(content_root) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Select a valid choice") + self.assertEqual(payload["theme"]["name"], "default") diff --git a/tests/apps/web/test_architecture_extensions.py b/tests/apps/web/test_architecture_extensions.py index e731e2b..8160c05 100644 --- a/tests/apps/web/test_architecture_extensions.py +++ b/tests/apps/web/test_architecture_extensions.py @@ -13,6 +13,22 @@ class ArchitectureExtensionTests(SimpleTestCase): + @staticmethod + def _create_theme(root: Path, theme_id: str) -> None: + theme_root = root / theme_id + (theme_root / "static").mkdir(parents=True) + (theme_root / "theme.toml").write_text( + '\n'.join( + [ + f'name = "{theme_id.title()}"', + f'description = "{theme_id} theme"', + 'version = "1.0.0"', + ] + ) + + "\n", + encoding="utf-8", + ) + def test_entity_registry_can_register_new_entity_type(self): registry = ContentEntityRegistry() registry.register( @@ -73,13 +89,12 @@ def list_entity_ids(self, *, prefix: str = ""): def test_theme_resolver_falls_back_to_default_theme(self): with TemporaryDirectory() as tmp: root = Path(tmp) - (root / "default" / "templates").mkdir(parents=True) - (root / "default" / "static").mkdir(parents=True) + self._create_theme(root, "default") - paths = ThemeResolver(root).resolve(ThemeSettings(name="custom")) + resolved = ThemeResolver(root).resolve(ThemeSettings(name="custom")) - self.assertEqual(paths.template_dir.name, "templates") - self.assertEqual(paths.static_dir.name, "static") + self.assertEqual(resolved.active_theme.theme_id, "default") + self.assertEqual(resolved.active_theme.static_dir.name, "static") def test_schema_validation_returns_normalized_payload_and_errors(self): normalized, errors = validate_record( diff --git a/tests/apps/web/test_views.py b/tests/apps/web/test_views.py index 15a418b..1d83adf 100644 --- a/tests/apps/web/test_views.py +++ b/tests/apps/web/test_views.py @@ -1,8 +1,13 @@ from unittest.mock import patch +from pathlib import Path +from tempfile import TemporaryDirectory + from django.test import TestCase, override_settings from django.urls import reverse +from mylonite.core.site_config_store import write_site_config_payload + @override_settings( STORAGES={ @@ -13,6 +18,46 @@ } ) class WebViewTests(TestCase): + @staticmethod + def _create_theme(theme_root: Path, theme_id: str, css: str | None = None) -> None: + root = theme_root / theme_id + (root / "static").mkdir(parents=True, exist_ok=True) + (root / "theme.toml").write_text( + '\n'.join( + [ + f'name = "{theme_id.title()}"', + f'description = "{theme_id} theme"', + 'version = "1.0.0"', + ] + ) + + "\n", + encoding="utf-8", + ) + if css is not None: + css_path = root / "static" / "css" / "site.css" + css_path.parent.mkdir(parents=True, exist_ok=True) + css_path.write_text(css, encoding="utf-8") + + @staticmethod + def _write_site_config(content_root: Path, theme_name: str) -> None: + write_site_config_payload( + content_root, + { + "site_title": "Site", + "site_url": "http://localhost:8000", + "owner_id": "identity.person.owner", + "footer_show_generated_by": True, + "footer_repository_url": "", + "hosting_mode": "local", + "public_domain": "", + "theme": {"name": theme_name, "custom_theme_allowed": True}, + "install": { + "setup_wizard_enabled": True, + "deferred_setup_allowed": True, + }, + }, + ) + def test_health_returns_ok_payload(self): response = self.client.get(reverse("health")) @@ -57,3 +102,84 @@ def test_homepage_uses_context_factory(self): self.assertEqual(response.status_code, 200) self.assertContains(response, "Test Owner") + + def test_theme_static_falls_back_to_default_asset(self): + with TemporaryDirectory() as themes_tmp, TemporaryDirectory() as content_tmp: + themes_root = Path(themes_tmp) + content_root = Path(content_tmp) + self._create_theme(themes_root, "default", css="body { color: red; }\n") + self._create_theme(themes_root, "ocean") + self._write_site_config(content_root, theme_name="ocean") + + with self.settings( + MYLONITE_THEMES_ROOT=themes_root, + MYLONITE_CONTENT_ROOT=content_root, + ): + response = self.client.get( + reverse( + "theme_static", + kwargs={"asset_path": "css/site.css"}, + ) + ) + + self.assertEqual(response.status_code, 200) + css = b"".join(response.streaming_content).decode("utf-8") + self.assertIn("color: red", css) + + def test_theme_static_merges_default_css_with_theme_overrides(self): + with TemporaryDirectory() as themes_tmp, TemporaryDirectory() as content_tmp: + themes_root = Path(themes_tmp) + content_root = Path(content_tmp) + self._create_theme(themes_root, "default", css="body { color: red; }\n") + self._create_theme(themes_root, "ocean", css="body { color: blue; }\n") + self._write_site_config(content_root, theme_name="ocean") + + with self.settings( + MYLONITE_THEMES_ROOT=themes_root, + MYLONITE_CONTENT_ROOT=content_root, + ): + response = self.client.get( + reverse( + "theme_static", + kwargs={"asset_path": "css/site.css"}, + ) + ) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Theme overrides (ocean)") + self.assertContains(response, "color: red") + self.assertContains(response, "color: blue") + + def test_theme_static_does_not_merge_non_primary_css_assets(self): + with TemporaryDirectory() as themes_tmp, TemporaryDirectory() as content_tmp: + themes_root = Path(themes_tmp) + content_root = Path(content_tmp) + self._create_theme(themes_root, "default") + self._create_theme(themes_root, "ocean") + + default_print = themes_root / "default" / "static" / "css" / "print.css" + default_print.parent.mkdir(parents=True, exist_ok=True) + default_print.write_text("body { color: red; }\n", encoding="utf-8") + + ocean_print = themes_root / "ocean" / "static" / "css" / "print.css" + ocean_print.parent.mkdir(parents=True, exist_ok=True) + ocean_print.write_text("body { color: blue; }\n", encoding="utf-8") + + self._write_site_config(content_root, theme_name="ocean") + + with self.settings( + MYLONITE_THEMES_ROOT=themes_root, + MYLONITE_CONTENT_ROOT=content_root, + ): + response = self.client.get( + reverse( + "theme_static", + kwargs={"asset_path": "css/print.css"}, + ) + ) + + self.assertEqual(response.status_code, 200) + css = b"".join(response.streaming_content).decode("utf-8") + self.assertNotIn("Theme overrides (ocean)", css) + self.assertNotIn("color: red", css) + self.assertIn("color: blue", css) diff --git a/tests/mylonite/core/test_core_modules.py b/tests/mylonite/core/test_core_modules.py index 9e74215..3df42e4 100644 --- a/tests/mylonite/core/test_core_modules.py +++ b/tests/mylonite/core/test_core_modules.py @@ -20,6 +20,22 @@ class CoreModulesTests(TestCase): + @staticmethod + def _create_theme(root: Path, theme_id: str) -> None: + theme_root = root / theme_id + (theme_root / "static").mkdir(parents=True) + (theme_root / "theme.toml").write_text( + '\n'.join( + [ + f'name = "{theme_id.title()}"', + f'description = "{theme_id} theme"', + 'version = "1.0.0"', + ] + ) + + "\n", + encoding="utf-8", + ) + def test_validation_status_to_dict(self): status = ValidationStatus(has_errors=True, errors=["site: site_url: required"]) @@ -58,15 +74,42 @@ def test_validate_record_reports_missing_required_field(self): def test_theme_resolver_prefers_existing_requested_theme(self): with TemporaryDirectory() as tmp: root = Path(tmp) - (root / "default" / "templates").mkdir(parents=True) - (root / "default" / "static").mkdir(parents=True) - (root / "ocean" / "templates").mkdir(parents=True) - (root / "ocean" / "static").mkdir(parents=True) + self._create_theme(root, "default") + self._create_theme(root, "ocean") + + resolved = ThemeResolver(root).resolve(ThemeSettings(name="ocean")) + + self.assertEqual(resolved.active_theme.theme_id, "ocean") + self.assertEqual(resolved.active_theme.static_dir.parent.name, "ocean") + + def test_theme_resolver_reports_missing_required_static_assets(self): + with TemporaryDirectory() as tmp: + root = Path(tmp) + self._create_theme(root, "default") + self._create_theme(root, "minimal") + + (root / "default" / "static" / "css").mkdir(parents=True, exist_ok=True) + (root / "default" / "static" / "css" / "site.css").write_text( + "body { color: red; }\n", + encoding="utf-8", + ) + + resolved = ThemeResolver(root).resolve(ThemeSettings(name="minimal")) + + self.assertEqual( + resolved.missing_required_static_files, + ("css/site.css",), + ) + + def test_theme_resolver_ignores_invalid_theme_folders(self): + with TemporaryDirectory() as tmp: + root = Path(tmp) + self._create_theme(root, "default") + (root / "broken-theme" / "static").mkdir(parents=True, exist_ok=True) - paths = ThemeResolver(root).resolve(ThemeSettings(name="ocean")) + themes = ThemeResolver(root).discover_themes() - self.assertEqual(paths.template_dir.parent.name, "ocean") - self.assertEqual(paths.static_dir.parent.name, "ocean") + self.assertEqual([theme.theme_id for theme in themes], ["default"]) def test_build_cache_key_changes_for_variant(self): base = BuildInput(site_id="site", content_version="1", theme_name="default") diff --git a/tests/mylonite/test_urls.py b/tests/mylonite/test_urls.py index 386fc19..b1cc5f3 100644 --- a/tests/mylonite/test_urls.py +++ b/tests/mylonite/test_urls.py @@ -12,6 +12,12 @@ def test_root_routes_to_home(self): def test_health_routes_to_health_view(self): self.assertEqual(resolve(reverse("health")).func, web_views.health) + def test_theme_static_routes_to_theme_static_view(self): + self.assertEqual( + resolve(reverse("theme_static", kwargs={"asset_path": "css/site.css"})).func, + web_views.theme_static, + ) + def test_panel_routes_resolve_to_expected_views(self): self.assertEqual( resolve(reverse("panel:root")).func.view_class, panel_views.AdminRootView diff --git a/themes/README.md b/themes/README.md new file mode 100644 index 0000000..e43328f --- /dev/null +++ b/themes/README.md @@ -0,0 +1,51 @@ +# Themes + +Mylonite themes are discovered from subfolders in `themes/`. + +Each theme folder must contain: + +1. `theme.toml` +2. `static/` directory +3. at minimum, a stylesheet entrypoint at `static/css/site.css` + +Only folders that match this format are shown as selectable options in the admin settings page. + +## Required metadata + +`theme.toml` must contain non-empty values for: + +```toml +name = "Theme Name" +description = "What this theme is for." +version = "1.0.0" +``` + +Folder names must match: `^[a-z0-9][a-z0-9._-]*$`. + +## Static-only scope + +- Themes do not include Django templates. +- Templates are fixed in `/templates` and shared across all themes. +- A theme controls only static assets under its `static/` folder. + +## Fallback behavior + +- `default` is the required fallback theme. +- If a selected theme is missing files that exist in `themes/default/static/`, + Mylonite logs a warning once and serves missing files from default. +- For the main stylesheet entrypoint (`css/site.css`) that exists in both + selected and default theme, Mylonite serves default CSS first and + selected-theme CSS second as overrides. + +## Example custom theme + +```text +themes/ + my-theme/ + theme.toml + static/ + css/ + site.css +``` + +After adding a valid theme folder, refresh `Admin -> Settings` and it will appear in the theme selector automatically. diff --git a/themes/default/README.md b/themes/default/README.md new file mode 100644 index 0000000..06f16c2 --- /dev/null +++ b/themes/default/README.md @@ -0,0 +1,34 @@ +# Default Theme + +This is the built-in theme shipped with Mylonite. +It is the canonical fallback for all theme assets. + +## Metadata + +`theme.toml` declares: + +- `name`: human-readable theme name +- `description`: short explanation shown in admin settings +- `version`: theme version + +## Assets + +- Main stylesheet entrypoint: `static/css/site.css` +- Static-only theme contract: all theme files must live under `static/` +- Template files are not part of the theme system + +## How fallback works + +- Mylonite always resolves missing theme assets from `themes/default/static/` +- For the main stylesheet (`css/site.css`), Mylonite serves default CSS first, then selected-theme CSS as overrides +- This keeps default styling values in place for anything not overridden by the custom theme + +## Required metadata file + +`theme.toml` must contain non-empty values: + +```toml +name = "Default" +description = "Built-in Mylonite theme with the standard visual style." +version = "1.0.0" +``` diff --git a/static/css/site.css b/themes/default/static/css/site.css similarity index 100% rename from static/css/site.css rename to themes/default/static/css/site.css diff --git a/themes/default/theme.toml b/themes/default/theme.toml new file mode 100644 index 0000000..d202378 --- /dev/null +++ b/themes/default/theme.toml @@ -0,0 +1,4 @@ +name = "Default" +description = "Built-in Mylonite theme with the standard visual style." +version = "1.0.0" + From 5b35970c1aacfb02428cf7cd9dc06ebeecb60203 Mon Sep 17 00:00:00 2001 From: remdui Date: Sun, 15 Mar 2026 19:29:47 +0100 Subject: [PATCH 2/2] Fix formatting (ruff) --- apps/panel/views.py | 20 +++++++++++-------- apps/web/views.py | 5 +---- mylonite/core/site_config_store.py | 1 - mylonite/core/theme_loader.py | 4 +++- mylonite/core/toml_utils.py | 1 - tests/apps/panel/test_views.py | 2 +- .../apps/web/test_architecture_extensions.py | 2 +- tests/apps/web/test_views.py | 2 +- tests/mylonite/core/test_core_modules.py | 2 +- tests/mylonite/test_urls.py | 4 +++- 10 files changed, 23 insertions(+), 20 deletions(-) diff --git a/apps/panel/views.py b/apps/panel/views.py index fabd1bb..4b57ff8 100644 --- a/apps/panel/views.py +++ b/apps/panel/views.py @@ -206,7 +206,9 @@ def _themes_root(self) -> Path: def _load_theme_context(self): resolver = ThemeResolver(self._themes_root) - site_theme_settings = load_active_theme_settings(content_root=self._content_root) + site_theme_settings = load_active_theme_settings( + content_root=self._content_root + ) discovered_themes = resolver.discover_themes() resolved_theme = resolver.resolve( site_theme_settings, @@ -253,13 +255,15 @@ def get_context_data(self, **kwargs): context["password_form"] = password_form context["theme_form"] = theme_form context["theme_options"] = theme_context["selectable_themes"] - context["active_theme_id"] = theme_context["resolved_theme"].active_theme.theme_id - context["custom_theme_allowed"] = ( - theme_context["site_theme_settings"].custom_theme_allowed - ) - context["missing_theme_files"] = ( - theme_context["resolved_theme"].missing_required_static_files - ) + context["active_theme_id"] = theme_context[ + "resolved_theme" + ].active_theme.theme_id + context["custom_theme_allowed"] = theme_context[ + "site_theme_settings" + ].custom_theme_allowed + context["missing_theme_files"] = theme_context[ + "resolved_theme" + ].missing_required_static_files return context def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: diff --git a/apps/web/views.py b/apps/web/views.py index d75c1e7..2673549 100644 --- a/apps/web/views.py +++ b/apps/web/views.py @@ -96,10 +96,7 @@ def get(self, request, asset_path: str): if resolved_asset is None: raise Http404("Theme asset not found.") - if ( - normalized_path.endswith(".css") - and not resolved_asset.from_fallback - ): + if normalized_path.endswith(".css") and not resolved_asset.from_fallback: css_response = self._build_css_response( resolved_theme=resolved_theme, normalized_path=normalized_path, diff --git a/mylonite/core/site_config_store.py b/mylonite/core/site_config_store.py index 81d430a..e57eb39 100644 --- a/mylonite/core/site_config_store.py +++ b/mylonite/core/site_config_store.py @@ -46,4 +46,3 @@ def write_site_config_payload(content_root: Path, payload: dict[str, Any]) -> No path = site_config_path(content_root) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(render_toml(payload), encoding="utf-8") - diff --git a/mylonite/core/theme_loader.py b/mylonite/core/theme_loader.py index ef735b5..0b52795 100644 --- a/mylonite/core/theme_loader.py +++ b/mylonite/core/theme_loader.py @@ -374,7 +374,9 @@ def _build_discovery_signature(self) -> tuple[tuple[str, int, int], ...]: metadata_mtime_ns = ( int(metadata_path.stat().st_mtime_ns) if metadata_path.exists() else -1 ) - static_mtime_ns = int(static_dir.stat().st_mtime_ns) if static_dir.exists() else -1 + static_mtime_ns = ( + int(static_dir.stat().st_mtime_ns) if static_dir.exists() else -1 + ) entries.append((child.name, metadata_mtime_ns, static_mtime_ns)) return tuple(entries) diff --git a/mylonite/core/toml_utils.py b/mylonite/core/toml_utils.py index d0d5734..a00285d 100644 --- a/mylonite/core/toml_utils.py +++ b/mylonite/core/toml_utils.py @@ -71,4 +71,3 @@ def render_toml(data: dict[str, Any]) -> str: ) return "\n".join(lines).rstrip() + "\n" - diff --git a/tests/apps/panel/test_views.py b/tests/apps/panel/test_views.py index dbc7dfa..f912803 100644 --- a/tests/apps/panel/test_views.py +++ b/tests/apps/panel/test_views.py @@ -32,7 +32,7 @@ def _create_theme(theme_root: Path, theme_id: str) -> None: root = theme_root / theme_id (root / "static" / "css").mkdir(parents=True, exist_ok=True) (root / "theme.toml").write_text( - '\n'.join( + "\n".join( [ f'name = "{theme_id.title()}"', f'description = "{theme_id} theme"', diff --git a/tests/apps/web/test_architecture_extensions.py b/tests/apps/web/test_architecture_extensions.py index 8160c05..4490314 100644 --- a/tests/apps/web/test_architecture_extensions.py +++ b/tests/apps/web/test_architecture_extensions.py @@ -18,7 +18,7 @@ def _create_theme(root: Path, theme_id: str) -> None: theme_root = root / theme_id (theme_root / "static").mkdir(parents=True) (theme_root / "theme.toml").write_text( - '\n'.join( + "\n".join( [ f'name = "{theme_id.title()}"', f'description = "{theme_id} theme"', diff --git a/tests/apps/web/test_views.py b/tests/apps/web/test_views.py index 1d83adf..666191b 100644 --- a/tests/apps/web/test_views.py +++ b/tests/apps/web/test_views.py @@ -23,7 +23,7 @@ def _create_theme(theme_root: Path, theme_id: str, css: str | None = None) -> No root = theme_root / theme_id (root / "static").mkdir(parents=True, exist_ok=True) (root / "theme.toml").write_text( - '\n'.join( + "\n".join( [ f'name = "{theme_id.title()}"', f'description = "{theme_id} theme"', diff --git a/tests/mylonite/core/test_core_modules.py b/tests/mylonite/core/test_core_modules.py index 3df42e4..27dc87a 100644 --- a/tests/mylonite/core/test_core_modules.py +++ b/tests/mylonite/core/test_core_modules.py @@ -25,7 +25,7 @@ def _create_theme(root: Path, theme_id: str) -> None: theme_root = root / theme_id (theme_root / "static").mkdir(parents=True) (theme_root / "theme.toml").write_text( - '\n'.join( + "\n".join( [ f'name = "{theme_id.title()}"', f'description = "{theme_id} theme"', diff --git a/tests/mylonite/test_urls.py b/tests/mylonite/test_urls.py index b1cc5f3..e4ddab2 100644 --- a/tests/mylonite/test_urls.py +++ b/tests/mylonite/test_urls.py @@ -14,7 +14,9 @@ def test_health_routes_to_health_view(self): def test_theme_static_routes_to_theme_static_view(self): self.assertEqual( - resolve(reverse("theme_static", kwargs={"asset_path": "css/site.css"})).func, + resolve( + reverse("theme_static", kwargs={"asset_path": "css/site.css"}) + ).func, web_views.theme_static, )