"""Tornado handlers for dynamic theme loading.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations import os import re from glob import glob from typing import Any, Generator from urllib.parse import urlparse from jupyter_server.base.handlers import FileFindHandler from jupyter_server.utils import url_path_join as ujoin class ThemesHandler(FileFindHandler): """A file handler that mangles local urls in CSS files.""" def initialize( self, path: str | list[str], default_filename: str | None = None, no_cache_paths: list[str] | None = None, themes_url: str | None = None, labextensions_path: list[str] | None = None, **kwargs: Any, # noqa: ARG002 ) -> None: """Initialize the handler.""" # Get all of the available theme paths in order labextensions_path = labextensions_path or [] ext_paths: list[str] = [] for ext_dir in labextensions_path: theme_pattern = ext_dir + "/**/themes" ext_paths.extend(path for path in glob(theme_pattern, recursive=True)) # Add the core theme path last if not isinstance(path, list): path = [path] path = ext_paths + path FileFindHandler.initialize( self, path, default_filename=default_filename, no_cache_paths=no_cache_paths ) self.themes_url = themes_url def get_content( # type:ignore[override] self, abspath: str, start: int | None = None, end: int | None = None ) -> bytes | Generator[bytes, None, None]: """Retrieve the content of the requested resource which is located at the given absolute path. This method should either return a byte string or an iterator of byte strings. """ base, ext = os.path.splitext(abspath) if ext != ".css": return FileFindHandler.get_content(abspath, start, end) return self._get_css() def get_content_size(self) -> int: """Retrieve the total size of the resource at the given path.""" assert self.absolute_path is not None base, ext = os.path.splitext(self.absolute_path) if ext != ".css": return FileFindHandler.get_content_size(self) return len(self._get_css()) def _get_css(self) -> bytes: """Get the mangled css file contents.""" assert self.absolute_path is not None with open(self.absolute_path, "rb") as fid: data = fid.read().decode("utf-8") if not self.themes_url: return b"" basedir = os.path.dirname(self.path).replace(os.sep, "/") basepath = ujoin(self.themes_url, basedir) # Replace local paths with mangled paths. # We only match strings that are local urls, # e.g. `url('../foo.css')`, `url('images/foo.png')` pattern = r"url\('(.*)'\)|url\('(.*)'\)" def replacer(m: Any) -> Any: """Replace the matched relative url with the mangled url.""" group = m.group() # Get the part that matched part = next(g for g in m.groups() if g) # Ignore urls that start with `/` or have a protocol like `http`. parsed = urlparse(part) if part.startswith("/") or parsed.scheme: return group return group.replace(part, ujoin(basepath, part)) return re.sub(pattern, replacer, data).encode("utf-8")