Skip to content

Commit

Permalink
feat(Theme): Add .add_sass_layer_file() method
Browse files Browse the repository at this point in the history
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()`
  • Loading branch information
gadenbuie committed Dec 4, 2024
1 parent 059aac4 commit 67a999f
Show file tree
Hide file tree
Showing 2 changed files with 112 additions and 0 deletions.
73 changes: 73 additions & 0 deletions shiny/ui/_theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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"]

0 comments on commit 67a999f

Please sign in to comment.