zensical 0.0.7__cp310-abi3-macosx_10_12_x86_64.whl → 0.0.12__cp310-abi3-macosx_10_12_x86_64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of zensical might be problematic. Click here for more details.

Files changed (28) hide show
  1. zensical/__init__.py +4 -4
  2. zensical/bootstrap/.github/workflows/docs.yml +1 -0
  3. zensical/bootstrap/zensical.toml +3 -3
  4. zensical/config.py +151 -177
  5. zensical/extensions/emoji.py +20 -25
  6. zensical/extensions/links.py +31 -22
  7. zensical/extensions/preview.py +27 -39
  8. zensical/extensions/search.py +81 -81
  9. zensical/extensions/utilities/filter.py +3 -8
  10. zensical/main.py +34 -71
  11. zensical/markdown.py +19 -18
  12. zensical/templates/assets/javascripts/bundle.21aa498e.min.js +3 -0
  13. zensical/templates/assets/javascripts/workers/{search.5e1f2129.min.js → search.5df7522c.min.js} +1 -1
  14. zensical/templates/assets/stylesheets/classic/main.6f483be1.min.css +1 -0
  15. zensical/templates/assets/stylesheets/modern/main.09f707be.min.css +1 -0
  16. zensical/templates/base.html +4 -4
  17. zensical/templates/partials/javascripts/base.html +1 -1
  18. zensical/templates/partials/nav-item.html +1 -1
  19. zensical/zensical.abi3.so +0 -0
  20. zensical/zensical.pyi +5 -11
  21. {zensical-0.0.7.dist-info → zensical-0.0.12.dist-info}/METADATA +6 -2
  22. {zensical-0.0.7.dist-info → zensical-0.0.12.dist-info}/RECORD +25 -25
  23. {zensical-0.0.7.dist-info → zensical-0.0.12.dist-info}/WHEEL +1 -1
  24. zensical/templates/assets/javascripts/bundle.6b62de02.min.js +0 -3
  25. zensical/templates/assets/stylesheets/classic/main.6eec86b3.min.css +0 -1
  26. zensical/templates/assets/stylesheets/modern/main.21338c02.min.css +0 -1
  27. {zensical-0.0.7.dist-info → zensical-0.0.12.dist-info}/entry_points.txt +0 -0
  28. {zensical-0.0.7.dist-info → zensical-0.0.12.dist-info}/licenses/LICENSE.md +0 -0
zensical/__init__.py CHANGED
@@ -21,8 +21,8 @@
21
21
  # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
22
22
  # IN THE SOFTWARE.
23
23
 
24
- from .zensical import *
24
+ from zensical.zensical import * # noqa: F403
25
25
 
26
- __doc__ = zensical.__doc__
27
- if hasattr(zensical, "__all__"):
28
- __all__ = zensical.__all__
26
+ __doc__ = zensical.__doc__ # type: ignore[name-defined] # noqa: F405
27
+ if hasattr(zensical, "__all__"): # type: ignore[name-defined] # noqa: F405
28
+ __all__ = zensical.__all__ # type: ignore[name-defined] # noqa: F405
@@ -26,3 +26,4 @@ jobs:
26
26
  with:
27
27
  path: site
28
28
  - uses: actions/deploy-pages@v4
29
+ id: deployment
@@ -57,7 +57,7 @@ Copyright © 2025 The authors
57
57
  #
58
58
  # Read more: https://zensical.org/docs/customization/#additional-css
59
59
  #
60
- #extra_css = ["assets/stylesheets/extra.css"]
60
+ #extra_css = ["stylesheets/extra.css"]
61
61
 
62
62
  # With the `extra_javascript` option you can add your own JavaScript to your
63
63
  # project to customize the behavior according to your needs.
@@ -65,7 +65,7 @@ Copyright © 2025 The authors
65
65
  # The path provided should be relative to the "docs_dir".
66
66
  #
67
67
  # Read more: https://zensical.org/docs/customization/#additional-javascript
68
- #extra_javascript = ["assets/javascript/extra.js"]
68
+ #extra_javascript = ["javascripts/extra.js"]
69
69
 
70
70
  # ----------------------------------------------------------------------------
71
71
  # Section for configuring theme options
@@ -93,7 +93,7 @@ Copyright © 2025 The authors
93
93
  # - https://zensical.org/docs/setup/logo-and-icons/#favicon
94
94
  # - https://developer.mozilla.org/en-US/docs/Glossary/Favicon
95
95
  #
96
- #favicon = "assets/images/favicon.png"
96
+ #favicon = "images/favicon.png"
97
97
 
98
98
  # Zensical supports more than 60 different languages. This means that the
99
99
  # labels and tooltips that Zensical's templates produce are translated.
zensical/config.py CHANGED
@@ -23,24 +23,26 @@
23
23
 
24
24
  from __future__ import annotations
25
25
 
26
+ import hashlib
26
27
  import importlib
27
28
  import os
28
- import yaml
29
-
30
- try:
31
- import tomllib
32
- except ModuleNotFoundError:
33
- import tomli as tomllib # type: ignore
29
+ import pickle
30
+ from typing import IO, Any
31
+ from urllib.parse import urlparse
34
32
 
33
+ import yaml
35
34
  from click import ClickException
36
35
  from deepmerge import always_merger
37
- from functools import partial
38
- from typing import Any, IO
39
36
  from yaml import BaseLoader, Loader, YAMLError
40
37
  from yaml.constructor import ConstructorError
41
- from urllib.parse import urlparse
42
38
 
43
- from .extensions.emoji import to_svg, twemoji
39
+ from zensical.extensions.emoji import to_svg, twemoji
40
+
41
+ try:
42
+ import tomllib
43
+ except ModuleNotFoundError:
44
+ import tomli as tomllib # type: ignore[no-redef]
45
+
44
46
 
45
47
  # ----------------------------------------------------------------------------
46
48
  # Globals
@@ -63,9 +65,7 @@ side, and use it directly when needed. It's a hack but will do for now.
63
65
 
64
66
 
65
67
  class ConfigurationError(ClickException):
66
- """
67
- Configuration resolution or validation failed.
68
- """
68
+ """Configuration resolution or validation failed."""
69
69
 
70
70
 
71
71
  # ----------------------------------------------------------------------------
@@ -74,20 +74,17 @@ class ConfigurationError(ClickException):
74
74
 
75
75
 
76
76
  def parse_config(path: str) -> dict:
77
- """
78
- Parse configuration file.
79
- """
80
- if path.endswith("zensical.toml"):
77
+ """Parse configuration file."""
78
+ # Decide by extension; no need to convert to Path
79
+ _, ext = os.path.splitext(path)
80
+ if ext.lower() == ".toml":
81
81
  return parse_zensical_config(path)
82
- else:
83
- return parse_mkdocs_config(path)
82
+ return parse_mkdocs_config(path)
84
83
 
85
84
 
86
85
  def parse_zensical_config(path: str) -> dict:
87
- """
88
- Parse zensical.toml configuration file.
89
- """
90
- global _CONFIG
86
+ """Parse zensical.toml configuration file."""
87
+ global _CONFIG # noqa: PLW0603
91
88
  with open(path, "rb") as f:
92
89
  config = tomllib.load(f)
93
90
  if "project" in config:
@@ -99,11 +96,9 @@ def parse_zensical_config(path: str) -> dict:
99
96
 
100
97
 
101
98
  def parse_mkdocs_config(path: str) -> dict:
102
- """
103
- Parse mkdocs.yml configuration file.
104
- """
105
- global _CONFIG
106
- with open(path, "r") as f:
99
+ """Parse mkdocs.yml configuration file."""
100
+ global _CONFIG # noqa: PLW0603
101
+ with open(path, encoding="utf-8") as f:
107
102
  config = _yaml_load(f)
108
103
 
109
104
  # Apply defaults and return parsed configuration
@@ -111,17 +106,20 @@ def parse_mkdocs_config(path: str) -> dict:
111
106
  return _CONFIG
112
107
 
113
108
 
109
+ def get_config() -> dict:
110
+ """Return configuration."""
111
+ # We assume this function is only called after populating `_CONFIG`.
112
+ return _CONFIG # type: ignore[return-value]
113
+
114
+
114
115
  def get_theme_dir() -> str:
115
- """
116
- Return the theme directory.
117
- """
116
+ """Return the theme directory."""
118
117
  path = os.path.dirname(os.path.abspath(__file__))
119
118
  return os.path.join(path, "templates")
120
119
 
121
120
 
122
121
  def _apply_defaults(config: dict, path: str) -> dict:
123
- """
124
- Apply default settings in configuration.
122
+ """Apply default settings in configuration.
125
123
 
126
124
  Note that this is loosely based on the defaults that MkDocs sets in its own
127
125
  configuration system, which we won't port for compatibility right now, as
@@ -136,12 +134,12 @@ def _apply_defaults(config: dict, path: str) -> dict:
136
134
 
137
135
  # Set site directory
138
136
  set_default(config, "site_dir", "site", str)
139
- if ".." in config.get("site_dir"):
137
+ if ".." in config.get("site_dir", ""):
140
138
  raise ConfigurationError("site_dir must not contain '..'")
141
139
 
142
140
  # Set docs directory
143
141
  set_default(config, "docs_dir", "docs", str)
144
- if ".." in config.get("docs_dir"):
142
+ if ".." in config.get("docs_dir", ""):
145
143
  raise ConfigurationError("docs_dir must not contain '..'")
146
144
 
147
145
  # Set defaults for core settings
@@ -159,21 +157,26 @@ def _apply_defaults(config: dict, path: str) -> dict:
159
157
  set_default(config, "edit_uri", None, str)
160
158
 
161
159
  # Set defaults for repository name settings
160
+ docs_dir = config.get("docs_dir")
161
+ repo_names = {
162
+ "github.com": "GitHub",
163
+ "gitlab.com": "Gitlab",
164
+ "bitbucket.org": "Bitbucket"
165
+ }
166
+ edit_uris = {
167
+ "github.com": f"edit/master/{docs_dir}",
168
+ "gitlab.com": f"edit/master/{docs_dir}",
169
+ "bitbucket.org": f"src/default/{docs_dir}"
170
+ }
162
171
  repo_url = config.get("repo_url")
163
- if repo_url and not config.get("repo_name"):
164
- docs_dir = config.get("docs_dir")
172
+ if repo_url:
165
173
  host = urlparse(repo_url).hostname or ""
166
- if host == "github.com":
167
- set_default(config, "repo_name", "GitHub", str)
168
- set_default(config, "edit_uri", f"edit/master/{docs_dir}", str)
169
- elif host == "gitlab.com":
170
- set_default(config, "repo_name", "GitLab", str)
171
- set_default(config, "edit_uri", f"edit/master/{docs_dir}", str)
172
- elif host == "bitbucket.org":
173
- set_default(config, "repo_name", "Bitbucket", str)
174
- set_default(config, "edit_uri", f"src/default/{docs_dir}", str)
174
+ if not config.get("repo_name") and host in repo_names:
175
+ set_default(config, "repo_name", repo_names[host], str)
175
176
  elif host:
176
177
  config["repo_name"] = host.split(".")[0].title()
178
+ if host in edit_uris:
179
+ set_default(config, "edit_uri", edit_uris[host], str)
177
180
 
178
181
  # Remove trailing slash from edit_uri if present
179
182
  edit_uri = config.get("edit_uri")
@@ -282,73 +285,13 @@ def _apply_defaults(config: dict, path: str) -> dict:
282
285
  set_default(toggle, "name", None, str)
283
286
 
284
287
  # Set defaults for extra settings
288
+ if "extra" in config and not isinstance(config["extra"], dict):
289
+ raise ConfigurationError("The 'extra' setting must be a mapping/dictionary.")
285
290
  extra = set_default(config, "extra", {}, dict)
286
- set_default(extra, "homepage", None, str)
287
- set_default(extra, "scope", None, str)
288
- set_default(extra, "annotate", {}, dict)
289
- set_default(extra, "tags", {}, dict)
290
- set_default(extra, "generator", True, bool)
291
+
292
+ if "polyfills" in extra and not isinstance(extra["polyfills"], list):
293
+ raise ConfigurationError("The 'extra.polyfills' setting must be a list.")
291
294
  set_default(extra, "polyfills", [], list)
292
- set_default(extra, "analytics", None, dict)
293
-
294
- # Set defaults for extra analytics settings
295
- analytics = extra.get("analytics")
296
- if analytics:
297
- set_default(analytics, "provider", None, str)
298
- set_default(analytics, "property", None, str)
299
- set_default(analytics, "feedback", None, dict)
300
-
301
- # Set defaults for extra analytics feedback settings
302
- feedback = analytics.get("feedback")
303
- if feedback:
304
- set_default(feedback, "title", None, str)
305
- set_default(feedback, "ratings", [], list)
306
-
307
- # Set defaults for each rating entry
308
- ratings = feedback.setdefault("ratings", [])
309
- for entry in ratings:
310
- set_default(entry, "icon", None, str)
311
- set_default(entry, "name", None, str)
312
- set_default(entry, "data", None, str)
313
- set_default(entry, "note", None, str)
314
-
315
- # Set defaults for extra consent settings
316
- consent = extra.setdefault("consent", None)
317
- if consent:
318
- set_default(consent, "title", None, str)
319
- set_default(consent, "description", None, str)
320
- set_default(consent, "actions", [], list)
321
-
322
- # Set defaults for extra consent cookie settings
323
- cookies = consent.setdefault("cookies", {})
324
- for key, value in cookies.items():
325
- if isinstance(value, str):
326
- cookies[key] = {"name": value, "checked": False}
327
-
328
- # Set defaults for each cookie entry
329
- set_default(cookies[key], "name", None, str)
330
- set_default(cookies[key], "checked", False, bool)
331
-
332
- # Set defaults for extra social settings
333
- social = extra.setdefault("social", [])
334
- for entry in social:
335
- set_default(entry, "icon", None, str)
336
- set_default(entry, "name", None, str)
337
- set_default(entry, "link", None, str)
338
-
339
- # Set defaults for extra alternate settings
340
- alternate = extra.setdefault("alternate", [])
341
- for entry in alternate:
342
- set_default(entry, "name", None, str)
343
- set_default(entry, "link", None, str)
344
- set_default(entry, "lang", None, str)
345
-
346
- # Set defaults for extra version settings
347
- version = extra.setdefault("version", None)
348
- if version:
349
- set_default(version, "provider", None, str)
350
- set_default(version, "default", None, str)
351
- set_default(version, "alias", False, bool)
352
295
 
353
296
  # Ensure all non-existent values are all empty strings (for now)
354
297
  config["extra"] = _convert_extra(extra)
@@ -425,31 +368,54 @@ def _apply_defaults(config: dict, path: str) -> dict:
425
368
  tabbed = config["mdx_configs"].get("pymdownx.tabbed", {})
426
369
  if isinstance(tabbed.get("slugify"), dict):
427
370
  object = tabbed["slugify"].get("object", "pymdownx.slugs.slugify")
428
- tabbed["slugify"] = partial(
429
- _resolve(object), tabbed["slugify"].get("kwds")
430
- )
371
+ tabbed["slugify"] = _resolve(object)(**tabbed["slugify"].get("kwds", {}))
372
+
373
+ # Table of contents extension configuration - resolve slugification function
374
+ toc = config["mdx_configs"]["toc"]
375
+ if isinstance(toc.get("slugify"), dict):
376
+ object = toc["slugify"].get("object", "pymdownx.slugs.slugify")
377
+ toc["slugify"] = _resolve(object)(**toc["slugify"].get("kwds", {}))
431
378
 
432
379
  # Superfences extension configuration - resolve format function
433
380
  superfences = config["mdx_configs"].get("pymdownx.superfences", {})
434
381
  for fence in superfences.get("custom_fences", []):
435
382
  if isinstance(fence.get("format"), str):
436
383
  fence["format"] = _resolve(fence.get("format"))
384
+ elif isinstance(fence.get("format"), dict):
385
+ object = fence["format"].get("object", "pymdownx.superfences.fence_code_format")
386
+ fence["format"] = _resolve(object)(**fence["format"].get("kwds", {}))
387
+ if isinstance(fence.get("validator"), str):
388
+ fence["validator"] = _resolve(fence.get("validator"))
389
+ elif isinstance(fence.get("validator"), dict):
390
+ object = fence["validator"].get("object")
391
+ callable_object = _resolve(object) if object else lambda *args, **kwargs: True
392
+ fence["validator"] = callable_object(**fence["validator"].get("kwds", {}))
437
393
 
438
394
  # Ensure the table of contents title is initialized, as it's used inside
439
395
  # the template, and the table of contents extension is always defined
440
396
  config["mdx_configs"]["toc"].setdefault("title", None)
397
+ config["mdx_configs_hash"] = _hash(mdx_configs)
441
398
 
442
399
  # Convert plugins configuration
443
400
  config["plugins"] = _convert_plugins(config.get("plugins", []), config)
401
+
402
+ # mkdocstrings configuration
403
+ if "mkdocstrings" in config["plugins"]:
404
+ mkdocstrings_config = config["plugins"]["mkdocstrings"]["config"]
405
+ if mkdocstrings_config.pop("enabled", True):
406
+ mkdocstrings_config["markdown_extensions"] = [
407
+ {ext: mdx_configs.get(ext, {})} for ext in markdown_extensions
408
+ ]
409
+ config["markdown_extensions"].append("mkdocstrings")
410
+ config["mdx_configs"]["mkdocstrings"] = mkdocstrings_config
411
+
444
412
  return config
445
413
 
446
414
 
447
415
  def set_default(
448
- entry: dict, key: str, default: Any, data_type: type = None
449
- ) -> any:
450
- """
451
- Set a key to a default value if it isn't set, and optionally cast it to the specified data type.
452
- """
416
+ entry: dict, key: str, default: Any, data_type: type | None = None
417
+ ) -> Any:
418
+ """Set a key to a default value if it isn't set, and optionally cast it to the specified data type."""
453
419
  if key in entry and entry[key] is None:
454
420
  del entry[key]
455
421
 
@@ -461,16 +427,22 @@ def set_default(
461
427
  try:
462
428
  entry[key] = data_type(entry[key])
463
429
  except (ValueError, TypeError) as e:
464
- raise ValueError(f"Failed to cast key '{key}' to {data_type}: {e}")
430
+ raise ValueError(
431
+ f"Failed to cast key '{key}' to {data_type}: {e}"
432
+ ) from e
465
433
 
466
434
  # Return the resulting value
467
435
  return entry[key]
468
436
 
469
437
 
438
+ def _hash(data: Any) -> int:
439
+ """Compute a hash for the given data."""
440
+ hash = hashlib.sha1(pickle.dumps(data)) # noqa: S324
441
+ return int(hash.hexdigest(), 16) % (2**64)
442
+
443
+
470
444
  def _convert_extra(data: dict | list) -> dict | list:
471
- """
472
- Recursively convert all None values in a dictionary or list to empty strings.
473
- """
445
+ """Recursively convert all None values in a dictionary or list to empty strings."""
474
446
  if isinstance(data, dict):
475
447
  # Process each key-value pair in the dictionary
476
448
  return {
@@ -479,7 +451,7 @@ def _convert_extra(data: dict | list) -> dict | list:
479
451
  else ("" if value is None else value)
480
452
  for key, value in data.items()
481
453
  }
482
- elif isinstance(data, list):
454
+ if isinstance(data, list):
483
455
  # Process each item in the list
484
456
  return [
485
457
  _convert_extra(item)
@@ -487,14 +459,11 @@ def _convert_extra(data: dict | list) -> dict | list:
487
459
  else ("" if item is None else item)
488
460
  for item in data
489
461
  ]
490
- else:
491
- return data
462
+ return data
492
463
 
493
464
 
494
- def _resolve(symbol: str):
495
- """
496
- Resolve a symbol to its corresponding Python object.
497
- """
465
+ def _resolve(symbol: str) -> Any:
466
+ """Resolve a symbol to its corresponding Python object."""
498
467
  module_path, func_name = symbol.rsplit(".", 1)
499
468
  module = importlib.import_module(module_path)
500
469
  return getattr(module, func_name)
@@ -503,17 +472,15 @@ def _resolve(symbol: str):
503
472
  # -----------------------------------------------------------------------------
504
473
 
505
474
 
506
- def _convert_nav(nav: dict) -> dict:
507
- """
508
- Convert MkDocs navigation
509
- """
475
+ def _convert_nav(nav: list) -> list:
476
+ """Convert MkDocs navigation."""
510
477
  return [_convert_nav_item(entry) for entry in nav]
511
478
 
512
479
 
513
- def _convert_nav_item(item: str | dict | list) -> dict:
514
- """
515
- Convert MkDocs shorthand navigation structure into something more manageable
516
- as we need to annotate each item with a title, URL, icon, and children.
480
+ def _convert_nav_item(item: str | dict | list) -> dict | list:
481
+ """Convert MkDocs shorthand navigation structure into something more manageable.
482
+
483
+ We need to annotate each item with a title, URL, icon, and children.
517
484
  """
518
485
  if isinstance(item, str):
519
486
  return {
@@ -527,19 +494,19 @@ def _convert_nav_item(item: str | dict | list) -> dict:
527
494
  }
528
495
 
529
496
  # Handle Title: URL
530
- elif isinstance(item, dict):
497
+ if isinstance(item, dict):
531
498
  for title, value in item.items():
532
499
  if isinstance(value, str):
533
500
  return {
534
501
  "title": str(title),
535
- "url": value,
502
+ "url": value.strip(),
536
503
  "canonical_url": None,
537
504
  "meta": None,
538
505
  "children": [],
539
- "is_index": _is_index(value),
506
+ "is_index": _is_index(value.strip()),
540
507
  "active": False,
541
508
  }
542
- elif isinstance(value, list):
509
+ if isinstance(value, list):
543
510
  return {
544
511
  "title": str(title),
545
512
  "url": None,
@@ -549,28 +516,25 @@ def _convert_nav_item(item: str | dict | list) -> dict:
549
516
  "is_index": False,
550
517
  "active": False,
551
518
  }
519
+ raise TypeError(f"Unknown nav item value type: {type(value)}")
552
520
 
553
521
  # Handle a list of items
554
522
  elif isinstance(item, list):
555
523
  return [_convert_nav_item(child) for child in item]
556
- else:
557
- raise ValueError(f"Unknown nav item type: {type(item)}")
524
+
525
+ raise TypeError(f"Unknown nav item type: {type(item)}")
558
526
 
559
527
 
560
528
  def _is_index(path: str) -> bool:
561
- """
562
- Returns, whether the given path points to a section index.
563
- """
564
- return path.endswith(("index.md", "README.md"))
529
+ """Returns, whether the given path points to a section index."""
530
+ return os.path.basename(path) in ("index.md", "README.md")
565
531
 
566
532
 
567
533
  # -----------------------------------------------------------------------------
568
534
 
569
535
 
570
- def _convert_extra_javascript(value: list[any]) -> list:
571
- """
572
- Ensure extra_javascript uses a structured format.
573
- """
536
+ def _convert_extra_javascript(value: list) -> list:
537
+ """Ensure extra_javascript uses a structured format."""
574
538
  for i, item in enumerate(value):
575
539
  if isinstance(item, str):
576
540
  value[i] = {
@@ -585,9 +549,7 @@ def _convert_extra_javascript(value: list[any]) -> list:
585
549
  item.setdefault("async", False)
586
550
  item.setdefault("defer", False)
587
551
  else:
588
- raise ValueError(
589
- f"Unknown extra_javascript item type: {type(item)}"
590
- )
552
+ raise TypeError(f"Unknown extra_javascript item type: {type(item)}")
591
553
 
592
554
  # Return resulting value
593
555
  return value
@@ -596,12 +558,10 @@ def _convert_extra_javascript(value: list[any]) -> list:
596
558
  # -----------------------------------------------------------------------------
597
559
 
598
560
 
599
- def _convert_markdown_extensions(value: any):
600
- """
601
- Convert Markdown extensions configuration to what Python Markdown expects.
602
- """
561
+ def _convert_markdown_extensions(value: Any) -> tuple[list[str], dict]:
562
+ """Convert Markdown extensions configuration to what Python Markdown expects."""
603
563
  markdown_extensions = ["toc", "tables"]
604
- mdx_configs = {"toc": {}, "tables": {}}
564
+ mdx_configs: dict[str, dict[str, Any]] = {"toc": {}, "tables": {}}
605
565
 
606
566
  # In case of Python Markdown Extensions, we allow to omit the necessary
607
567
  # quotes around the extension names, so we need to hoist the extensions
@@ -609,8 +569,24 @@ def _convert_markdown_extensions(value: any):
609
569
  # actually parse the configuration.
610
570
  if "pymdownx" in value:
611
571
  pymdownx = value.pop("pymdownx")
612
- for ext, config in pymdownx.items():
613
- value[f"pymdownx.{ext}"] = config
572
+ for ext, conf in pymdownx.items():
573
+ # Special case for blocks extension, which has another level of
574
+ # nesting. This is the only extension that requires this.
575
+ if ext == "blocks":
576
+ for block, config in conf.items():
577
+ value[f"pymdownx.{ext}.{block}"] = config
578
+ else:
579
+ value[f"pymdownx.{ext}"] = conf
580
+
581
+ # Same as for Python Markdown extensions, see above
582
+ if "zensical" in value:
583
+ zensical = value.pop("zensical")
584
+ for ext, conf in zensical.items():
585
+ if ext == "extensions":
586
+ for key, config in conf.items():
587
+ value[f"zensical.{ext}.{key}"] = config
588
+ else:
589
+ value[f"zensical.{ext}"] = conf
614
590
 
615
591
  # Extensions can be defined as a dict
616
592
  if isinstance(value, dict):
@@ -635,16 +611,13 @@ def _convert_markdown_extensions(value: any):
635
611
  # ----------------------------------------------------------------------------
636
612
 
637
613
 
638
- def _convert_plugins(value: any, config: dict) -> list:
639
- """
640
- Convert plugins configuration to something we can work with.
641
- """
614
+ def _convert_plugins(value: Any, config: dict) -> dict:
615
+ """Convert plugins configuration to something we can work with."""
642
616
  plugins = {}
643
617
 
644
618
  # Plugins can be defined as a dict
645
619
  if isinstance(value, dict):
646
- for name, data in value.items():
647
- plugins[name] = data
620
+ plugins.update(value)
648
621
 
649
622
  # Plugins can also be defined as a list
650
623
  else:
@@ -701,8 +674,7 @@ def _convert_plugins(value: any, config: dict) -> list:
701
674
  def _yaml_load(
702
675
  source: IO, loader: type[BaseLoader] | None = None
703
676
  ) -> dict[str, Any]:
704
- """
705
- Load configuration file and resolve environment variables and parent files.
677
+ """Load configuration file and resolve environment variables and parent files.
706
678
 
707
679
  Note that INHERIT is only a bandaid that was introduced to allow for some
708
680
  degree of modularity, but with serious shortcomings. Zensical will use a
@@ -717,12 +689,12 @@ def _yaml_load(
717
689
  source.read()
718
690
  .replace("material.extensions", "zensical.extensions")
719
691
  .replace("materialx", "zensical.extensions"),
720
- Loader=Loader,
692
+ Loader=Loader, # noqa: S506
721
693
  )
722
694
  except YAMLError as e:
723
695
  raise ConfigurationError(
724
696
  f"Encountered an error parsing the configuration file: {e}"
725
- )
697
+ ) from e
726
698
  if config is None:
727
699
  return {}
728
700
 
@@ -736,7 +708,7 @@ def _yaml_load(
736
708
  raise ConfigurationError(
737
709
  f"Inherited config file '{relpath}' doesn't exist at '{abspath}'."
738
710
  )
739
- with open(abspath, "r") as fd:
711
+ with open(abspath, encoding="utf-8") as fd:
740
712
  parent = _yaml_load(fd, loader)
741
713
  config = always_merger.merge(parent, config)
742
714
 
@@ -744,9 +716,11 @@ def _yaml_load(
744
716
  return config
745
717
 
746
718
 
747
- def _construct_env_tag(loader: yaml.Loader, node: yaml.Node):
748
- """
749
- Assign value of ENV variable referenced at node.
719
+ def _construct_env_tag(
720
+ loader: yaml.Loader,
721
+ node: yaml.ScalarNode | yaml.SequenceNode | yaml.MappingNode,
722
+ ) -> Any:
723
+ """Assign value of ENV variable referenced at node.
750
724
 
751
725
  MkDocs supports the use of !ENV to reference environment variables in YAML
752
726
  configuration files. We won't likely support this in Zensical, but for now
@@ -775,7 +749,7 @@ def _construct_env_tag(loader: yaml.Loader, node: yaml.Node):
775
749
  else:
776
750
  raise ConstructorError(
777
751
  context=f"expected a scalar or sequence node, but found {node.id}",
778
- start_mark=node.start_mark,
752
+ context_mark=node.start_mark,
779
753
  )
780
754
 
781
755
  # Resolve environment variable