Skip to content

Commit e802cfc

Browse files
gadenbuiecpsievert
andauthored
feat(Theme): Add .add_sass_layer_file() method (#1790)
Co-authored-by: Carson Sievert <cpsievert1@gmail.com>
1 parent 8773714 commit e802cfc

File tree

3 files changed

+145
-0
lines changed

3 files changed

+145
-0
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [UNRELEASED]
99

10+
### New features
11+
12+
* 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)
13+
1014
### Bug fixes
1115

1216
* `ui.Chat()` now correctly handles new `ollama.chat()` return value introduced in `ollama` v0.4. (#1787)

shiny/ui/_theme.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ def __init__(
149149
self._include_paths.append(str(path))
150150

151151
# User-provided Sass code
152+
self._uses: list[str] = []
152153
self._functions: list[str] = []
153154
self._defaults: list[str] = []
154155
self._mixins: list[str] = []
@@ -248,6 +249,24 @@ def _combine_args_kwargs(
248249

249250
return [textwrap.dedent(x) for x in values]
250251

252+
def add_uses(self: T, *args: str) -> T:
253+
"""
254+
Add custom Sass "uses" declarations to the theme.
255+
256+
Sass code added via this method will be placed **before** the function
257+
declarations from the theme preset, allowing you to add Sass code that appears
258+
before any other Sass code in the theme layer.
259+
260+
Parameters
261+
----------
262+
*args
263+
The Sass functions to add as a single or multiple strings.
264+
"""
265+
uses = self._combine_args_kwargs(*args, kwargs={})
266+
self._uses.extend(uses)
267+
self._reset_css()
268+
return self
269+
251270
def add_functions(self: T, *args: str) -> T:
252271
"""
253272
Add custom Sass functions to the theme.
@@ -356,6 +375,88 @@ def add_rules(
356375
self._reset_css()
357376
return self
358377

378+
def add_sass_layer_file(self: T, path: str | pathlib.Path) -> T:
379+
"""
380+
Add a Sass layer file to the theme.
381+
382+
This method reads a special `.scss` file formatted with layer boundary comments
383+
to denote regions of functions, defaults, mixins, and rules. It then splits the
384+
file into these constituent pieces and adds them to the appropriate layers of
385+
the theme.
386+
387+
The theme file should contain at least one of the following boundary comments:
388+
389+
```scss
390+
/*-- scss:uses --*/
391+
/*-- scss:functions --*/
392+
/*-- scss:defaults --*/
393+
/*-- scss:mixins --*/
394+
/*-- scss:rules --*/
395+
```
396+
397+
Each layer, once extracted, is added to the theme using the corresponding
398+
`add_` method, e.g. the `scss:rules` layer is added via `.add_rules()`.
399+
400+
Layer types can appear more than once in the `.scss` file. They are coalesced
401+
into a single layer by order of appearance and then added as a block via their
402+
corresponding `add_` method.
403+
404+
Parameters
405+
----------
406+
path
407+
The path to the `.scss` file to be added.
408+
409+
Raises
410+
------
411+
ValueError
412+
If the `.scss` file doesn't contain at least one valid region decorator.
413+
"""
414+
with open(path, "r") as file:
415+
src = file.readlines()
416+
417+
layer_keys = ["uses", "functions", "defaults", "mixins", "rules"]
418+
rx_pattern = re.compile(rf"^/\*--\s*scss:({'|'.join(layer_keys)})\s*--\*/$")
419+
420+
layer_boundaries = [rx_pattern.match(line.strip()) for line in src]
421+
422+
if not any(layer_boundaries):
423+
raise ValueError(
424+
f"The file {path} doesn't contain at least one layer boundary "
425+
f"(/*-- scss:{{{','.join(layer_keys)}}} --*/)",
426+
)
427+
428+
layers: dict[str, list[str]] = {}
429+
layer_name: str = ""
430+
for i, line in enumerate(src):
431+
layer_boundary = layer_boundaries[i]
432+
if layer_boundary:
433+
layer_name = layer_boundary.group(1)
434+
continue
435+
436+
if not layer_name:
437+
# Preamble lines are dropped (both in Quarto and {sass})
438+
continue
439+
440+
if layer_name not in layers:
441+
layers[layer_name] = []
442+
443+
layers[layer_name].append(line)
444+
445+
for key, value in layers.items():
446+
# Call the appropriate add_*() method to add each layer
447+
add_method = getattr(self, f"add_{key}", None)
448+
if add_method:
449+
add_method("".join(value))
450+
else:
451+
# We'd get here if we add a new layer boundary name but forget to
452+
# include it in the supported `.add_{layer}()` methods.
453+
raise ValueError(
454+
f"Unsupported Sass layer: {key}. Please report this issue to the "
455+
"Shiny maintainers at https://github.com/posit-dev/py-shiny."
456+
)
457+
458+
return self
459+
359460
def to_sass(self) -> str:
360461
"""
361462
Returns the custom theme as a single Sass string.
@@ -371,6 +472,7 @@ def to_sass(self) -> str:
371472
path_rules = path_pkg_preset(self._preset, "_04_rules.scss")
372473

373474
sass_lines = [
475+
*self._uses,
374476
f'@import "{path_functions}";',
375477
*self._functions,
376478
*self._defaults,

tests/pytest/test_theme.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import tempfile
12
from typing import Callable, Optional
23

34
import pytest
@@ -218,3 +219,41 @@ def test_theme_dependency_has_data_attribute():
218219

219220
theme = Theme("shiny", name="My Fancy Theme")
220221
assert theme._html_dependencies()[0].stylesheet[0]["data-shiny-theme"] == "My Fancy Theme" # type: ignore
222+
223+
224+
def test_theme_add_sass_layer_file():
225+
with tempfile.TemporaryDirectory() as temp_dir:
226+
with open(f"{temp_dir}/no-layers.scss", "w") as f:
227+
f.write("// no layers")
228+
229+
# Throws if no special layer boundary comments are found
230+
with pytest.raises(ValueError, match="one layer boundary"):
231+
Theme().add_sass_layer_file(f"{temp_dir}/no-layers.scss")
232+
233+
with open(f"{temp_dir}/layers.scss", "w") as temp_scss:
234+
temp_scss.write(
235+
"""
236+
/*-- scss:uses --*/
237+
// uses
238+
/*-- scss:functions --*/
239+
// functions
240+
/*-- scss:defaults --*/
241+
// defaults 1
242+
/*-- scss:mixins --*/
243+
// mixins
244+
/*-- scss:rules --*/
245+
// rules 1
246+
/*-- scss:defaults --*/
247+
// defaults 2
248+
/*-- scss:rules --*/
249+
// rules 2
250+
"""
251+
)
252+
253+
theme = Theme().add_sass_layer_file(temp_scss.name)
254+
255+
assert theme._uses == ["// uses\n"]
256+
assert theme._functions == ["// functions\n"]
257+
assert theme._defaults == ["// defaults 1\n// defaults 2\n"]
258+
assert theme._mixins == ["// mixins\n"]
259+
assert theme._rules == ["// rules 1\n// rules 2\n"]

0 commit comments

Comments
 (0)