Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Theme): Add .add_sass_layer_file() method #1790

Merged
merged 6 commits into from
Dec 4, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions shiny/ui/_theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -356,6 +375,81 @@ 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*--\*/$")

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)}}} --*/)",
)

layers: dict[str, list[str]] = {}
layer_name: str = ""
for i, line in enumerate(src):
layer_boundary = layer_boundaries[i]
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))
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved

return self

def to_sass(self) -> str:
"""
Returns the custom theme as a single Sass string.
Expand All @@ -371,6 +465,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,
Expand Down
39 changes: 39 additions & 0 deletions tests/pytest/test_theme.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import tempfile
from typing import Callable, Optional

import pytest
Expand Down Expand Up @@ -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"]
Loading