From 67a999f5609c982d979b43a726f79829ff685101 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 3 Dec 2024 22:46:55 -0500 Subject: [PATCH] feat(Theme): Add `.add_sass_layer_file()` method Follows the Quarto implementation as described in https://quarto.org/docs/output-formats/html-themes-more.html#bootstrap-bootswatch-layering See also https://rstudio.github.io/sass/reference/sass_layer.html this method is functionally equivalent to `sass::sass_layer_file()` --- shiny/ui/_theme.py | 73 ++++++++++++++++++++++++++++++++++++++ tests/pytest/test_theme.py | 39 ++++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/shiny/ui/_theme.py b/shiny/ui/_theme.py index 302b9e4d7..a94a114ad 100644 --- a/shiny/ui/_theme.py +++ b/shiny/ui/_theme.py @@ -375,6 +375,79 @@ def add_rules( self._reset_css() return self + def add_sass_layer_file(self: T, path: str | pathlib.Path) -> T: + """ + Add a Sass layer file to the theme. + + This method reads a special `.scss` file formatted with layer boundary comments + to denote regions of functions, defaults, mixins, and rules. It then splits the + file into these constituent pieces and adds them to the appropriate layers of + the theme. + + The theme file should contain at least one of the following boundary comments: + + ```scss + /*-- scss:uses --*/ + /*-- scss:functions --*/ + /*-- scss:defaults --*/ + /*-- scss:mixins --*/ + /*-- scss:rules --*/ + ``` + + Each layer, once extracted, is added to the theme using the corresponding + `add_` method, e.g. the `scss:rules` layer is added via `.add_rules()`. + + Layer types can appear more than once in the `.scss` file. They are coalesced + into a single layer by order of appearance and then added as a block via their + corresponding `add_` method. + + Parameters + ---------- + path + The path to the `.scss` file to be added. + + Raises + ------ + ValueError + If the `.scss` file doesn't contain at least one valid region decorator. + """ + with open(path, "r") as file: + src = file.readlines() + + layer_keys = ["uses", "functions", "defaults", "mixins", "rules"] + rx_pattern = re.compile(rf"^/\*--\s*scss:({'|'.join(layer_keys)})\s*--\*/$") + + if not any([rx_pattern.match(s) for s in src]): + raise ValueError( + f"The file {path} doesn't contain at least one layer boundary " + f"(/*-- scss:{{{','.join(layer_keys)}}} --*/)", + ) + + layers: dict[str, list[str]] = {} + layer_name: str = "" + for line in src: + layer_boundary = rx_pattern.match(line.strip()) + if layer_boundary: + layer_name = layer_boundary.group(1) + continue + + if not layer_name: + # Preamble lines are dropped (both in Quarto and {sass}) + continue + + if layer_name not in layers: + layers[layer_name] = [] + + layers[layer_name].append(line) + + for key, value in layers.items(): + # Call the appropriate add_*() method to add each layer + add_method = getattr(self, f"add_{key}", None) + if add_method: + add_method("".join(value)) + + return self + def to_sass(self) -> str: """ Returns the custom theme as a single Sass string. diff --git a/tests/pytest/test_theme.py b/tests/pytest/test_theme.py index 4a9291a2c..95b5f2850 100644 --- a/tests/pytest/test_theme.py +++ b/tests/pytest/test_theme.py @@ -1,3 +1,4 @@ +import tempfile from typing import Callable, Optional import pytest @@ -218,3 +219,41 @@ def test_theme_dependency_has_data_attribute(): theme = Theme("shiny", name="My Fancy Theme") assert theme._html_dependencies()[0].stylesheet[0]["data-shiny-theme"] == "My Fancy Theme" # type: ignore + + +def test_theme_add_sass_layer_file(): + with tempfile.TemporaryDirectory() as temp_dir: + with open(f"{temp_dir}/no-layers.scss", "w") as f: + f.write("// no layers") + + # Throws if no special layer boundary comments are found + with pytest.raises(ValueError, match="one layer boundary"): + Theme().add_sass_layer_file(f"{temp_dir}/no-layers.scss") + + with open(f"{temp_dir}/layers.scss", "w") as temp_scss: + temp_scss.write( + """ +/*-- scss:uses --*/ +// uses +/*-- scss:functions --*/ +// functions +/*-- scss:defaults --*/ +// defaults 1 +/*-- scss:mixins --*/ +// mixins +/*-- scss:rules --*/ +// rules 1 +/*-- scss:defaults --*/ +// defaults 2 +/*-- scss:rules --*/ +// rules 2 + """ + ) + + theme = Theme().add_sass_layer_file(temp_scss.name) + + assert theme._uses == ["// uses\n"] + assert theme._functions == ["// functions\n"] + assert theme._defaults == ["// defaults 1\n// defaults 2\n"] + assert theme._mixins == ["// mixins\n"] + assert theme._rules == ["// rules 1\n// rules 2\n"]