From 059aac4e468b975570a518a860d048483dddfc02 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 3 Dec 2024 21:33:59 -0500 Subject: [PATCH 1/5] feat(Theme): Add `.add_uses()` method --- shiny/ui/_theme.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/shiny/ui/_theme.py b/shiny/ui/_theme.py index cba938882..302b9e4d7 100644 --- a/shiny/ui/_theme.py +++ b/shiny/ui/_theme.py @@ -149,6 +149,7 @@ def __init__( self._include_paths.append(str(path)) # User-provided Sass code + self._uses: list[str] = [] self._functions: list[str] = [] self._defaults: list[str] = [] self._mixins: list[str] = [] @@ -248,6 +249,24 @@ def _combine_args_kwargs( return [textwrap.dedent(x) for x in values] + def add_uses(self: T, *args: str) -> T: + """ + Add custom Sass "uses" declarations to the theme. + + Sass code added via this method will be placed **before** the function + declarations from the theme preset, allowing you to add Sass code that appears + before any other Sass code in the theme layer. + + Parameters + ---------- + *args + The Sass functions to add as a single or multiple strings. + """ + uses = self._combine_args_kwargs(*args, kwargs={}) + self._uses.extend(uses) + self._reset_css() + return self + def add_functions(self: T, *args: str) -> T: """ Add custom Sass functions to the theme. @@ -371,6 +390,7 @@ def to_sass(self) -> str: path_rules = path_pkg_preset(self._preset, "_04_rules.scss") sass_lines = [ + *self._uses, f'@import "{path_functions}";', *self._functions, *self._defaults, From 67a999f5609c982d979b43a726f79829ff685101 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 3 Dec 2024 22:46:55 -0500 Subject: [PATCH 2/5] 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"] From 35707379c16a4f1c87cc176658699c7d779587f8 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 4 Dec 2024 10:01:26 -0500 Subject: [PATCH 3/5] refactor: Find layer boundaries once --- shiny/ui/_theme.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/shiny/ui/_theme.py b/shiny/ui/_theme.py index a94a114ad..249520fbd 100644 --- a/shiny/ui/_theme.py +++ b/shiny/ui/_theme.py @@ -417,7 +417,9 @@ def add_sass_layer_file(self: T, path: str | pathlib.Path) -> T: 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]): + layer_boundaries = [rx_pattern.match(line.strip()) for line in src] + + if not any(layer_boundaries): raise ValueError( f"The file {path} doesn't contain at least one layer boundary " f"(/*-- scss:{{{','.join(layer_keys)}}} --*/)", @@ -425,8 +427,8 @@ def add_sass_layer_file(self: T, path: str | pathlib.Path) -> T: layers: dict[str, list[str]] = {} layer_name: str = "" - for line in src: - layer_boundary = rx_pattern.match(line.strip()) + for i, line in enumerate(src): + layer_boundary = layer_boundaries[i] if layer_boundary: layer_name = layer_boundary.group(1) continue From ca32cdd058fbb782b8c14a572506c8366734dada Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 4 Dec 2024 12:32:51 -0500 Subject: [PATCH 4/5] chore: Throw if we add a new layer but forget to support it --- shiny/ui/_theme.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/shiny/ui/_theme.py b/shiny/ui/_theme.py index 249520fbd..52a91cd53 100644 --- a/shiny/ui/_theme.py +++ b/shiny/ui/_theme.py @@ -447,6 +447,13 @@ def add_sass_layer_file(self: T, path: str | pathlib.Path) -> T: add_method = getattr(self, f"add_{key}", None) if add_method: add_method("".join(value)) + else: + # We'd get here if we add a new layer boundary name but forget to + # include it in the supported `.add_{layer}()` methods. + raise ValueError( + f"Unsupported Sass layer: {key}. Please report this issue to the " + "Shiny maintainers at https://github.com/posit-dev/py-shiny." + ) return self From ec79e5b3b91d31ac49361bbf52ff57c4a8f6d4b7 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 4 Dec 2024 12:38:03 -0500 Subject: [PATCH 5/5] docs: Add changelog item --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index af2367286..e46ae7f0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [UNRELEASED] +### New features + +* Added a new `.add_sass_layer_file()` method to `ui.Theme` that supports reading a Sass file with layer boundary comments, e.g. `/*-- scss:defaults --*/`. This format [is supported by Quarto](https://quarto.org/docs/output-formats/html-themes-more.html#bootstrap-bootswatch-layering) and makes it easier to store Sass rules and declarations that need to be woven into Shiny's Sass Bootstrap files. (#1790) + ### Bug fixes * `ui.Chat()` now correctly handles new `ollama.chat()` return value introduced in `ollama` v0.4. (#1787)