themes_handler.py 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
  1. """Tornado handlers for dynamic theme loading."""
  2. # Copyright (c) Jupyter Development Team.
  3. # Distributed under the terms of the Modified BSD License.
  4. from __future__ import annotations
  5. import os
  6. import re
  7. from collections.abc import Generator
  8. from glob import glob
  9. from typing import Any
  10. from urllib.parse import urlparse
  11. from jupyter_server.base.handlers import FileFindHandler
  12. from jupyter_server.utils import url_path_join as ujoin
  13. class ThemesHandler(FileFindHandler):
  14. """A file handler that mangles local urls in CSS files."""
  15. def initialize(
  16. self,
  17. path: str | list[str],
  18. default_filename: str | None = None,
  19. no_cache_paths: list[str] | None = None,
  20. themes_url: str | None = None,
  21. labextensions_path: list[str] | None = None,
  22. **kwargs: Any, # noqa: ARG002
  23. ) -> None:
  24. """Initialize the handler."""
  25. # Get all of the available theme paths in order
  26. labextensions_path = labextensions_path or []
  27. ext_paths: list[str] = []
  28. for ext_dir in labextensions_path:
  29. theme_pattern = ext_dir + "/**/themes"
  30. ext_paths.extend(path for path in glob(theme_pattern, recursive=True))
  31. # Add the core theme path last
  32. if not isinstance(path, list):
  33. path = [path]
  34. path = ext_paths + path
  35. FileFindHandler.initialize(
  36. self, path, default_filename=default_filename, no_cache_paths=no_cache_paths
  37. )
  38. self.themes_url = themes_url
  39. def get_content( # type:ignore[override]
  40. self, abspath: str, start: int | None = None, end: int | None = None
  41. ) -> bytes | Generator[bytes, None, None]:
  42. """Retrieve the content of the requested resource which is located
  43. at the given absolute path.
  44. This method should either return a byte string or an iterator
  45. of byte strings.
  46. """
  47. base, ext = os.path.splitext(abspath)
  48. if ext != ".css":
  49. return FileFindHandler.get_content(abspath, start, end)
  50. return self._get_css()
  51. def get_content_size(self) -> int:
  52. """Retrieve the total size of the resource at the given path."""
  53. assert self.absolute_path is not None
  54. base, ext = os.path.splitext(self.absolute_path)
  55. if ext != ".css":
  56. return FileFindHandler.get_content_size(self)
  57. return len(self._get_css())
  58. def _get_css(self) -> bytes:
  59. """Get the mangled css file contents."""
  60. assert self.absolute_path is not None
  61. with open(self.absolute_path, "rb") as fid:
  62. data = fid.read().decode("utf-8")
  63. if not self.themes_url:
  64. return b""
  65. basedir = os.path.dirname(self.path).replace(os.sep, "/")
  66. basepath = ujoin(self.themes_url, basedir)
  67. # Replace local paths with mangled paths.
  68. # We only match strings that are local urls,
  69. # e.g. `url('../foo.css')`, `url('images/foo.png')`
  70. pattern = r"url\('(.*)'\)|url\('(.*)'\)"
  71. def replacer(m: Any) -> Any:
  72. """Replace the matched relative url with the mangled url."""
  73. group = m.group()
  74. # Get the part that matched
  75. part = next(g for g in m.groups() if g)
  76. # Ignore urls that start with `/` or have a protocol like `http`.
  77. parsed = urlparse(part)
  78. if part.startswith("/") or parsed.scheme:
  79. return group
  80. return group.replace(part, ujoin(basepath, part))
  81. return re.sub(pattern, replacer, data).encode("utf-8")