zensical 0.0.3__cp310-abi3-musllinux_1_2_i686.whl → 0.0.12__cp310-abi3-musllinux_1_2_i686.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 (34) hide show
  1. zensical/__init__.py +6 -6
  2. zensical/__main__.py +28 -0
  3. zensical/bootstrap/.github/workflows/docs.yml +10 -3
  4. zensical/bootstrap/zensical.toml +22 -22
  5. zensical/config.py +191 -197
  6. zensical/extensions/__init__.py +2 -2
  7. zensical/extensions/emoji.py +22 -27
  8. zensical/extensions/links.py +33 -24
  9. zensical/extensions/preview.py +29 -41
  10. zensical/extensions/search.py +83 -83
  11. zensical/extensions/utilities/__init__.py +2 -2
  12. zensical/extensions/utilities/filter.py +5 -10
  13. zensical/main.py +36 -47
  14. zensical/markdown.py +21 -20
  15. zensical/templates/assets/javascripts/bundle.21aa498e.min.js +3 -0
  16. zensical/templates/assets/javascripts/workers/{search.5e1f2129.min.js → search.5df7522c.min.js} +1 -1
  17. zensical/templates/assets/stylesheets/classic/main.6f483be1.min.css +1 -0
  18. zensical/templates/assets/stylesheets/modern/main.09f707be.min.css +1 -0
  19. zensical/templates/base.html +4 -4
  20. zensical/templates/partials/javascripts/base.html +1 -1
  21. zensical/templates/partials/nav-item.html +1 -1
  22. zensical/templates/partials/search.html +3 -1
  23. zensical/zensical.abi3.so +0 -0
  24. zensical/zensical.pyi +7 -13
  25. {zensical-0.0.3.dist-info → zensical-0.0.12.dist-info}/METADATA +9 -4
  26. {zensical-0.0.3.dist-info → zensical-0.0.12.dist-info}/RECORD +30 -29
  27. {zensical-0.0.3.dist-info → zensical-0.0.12.dist-info}/WHEEL +1 -1
  28. {zensical-0.0.3.dist-info → zensical-0.0.12.dist-info}/licenses/LICENSE.md +1 -1
  29. zensical.libs/libgcc_s-f5fcfe20.so.1 +0 -0
  30. zensical/templates/assets/javascripts/bundle.3c403d54.min.js +0 -3
  31. zensical/templates/assets/stylesheets/classic/main.c5ffb0a9.min.css +0 -1
  32. zensical/templates/assets/stylesheets/modern/main.1357c24d.min.css +0 -1
  33. zensical.libs/libgcc_s-27e5a392.so.1 +0 -0
  34. {zensical-0.0.3.dist-info → zensical-0.0.12.dist-info}/entry_points.txt +0 -0
zensical/config.py CHANGED
@@ -1,7 +1,7 @@
1
- # Copyright (c) Zensical LLC <https://zensical.org>
1
+ # Copyright (c) 2025 Zensical and contributors
2
2
 
3
3
  # SPDX-License-Identifier: MIT
4
- # Third-party contributions licensed under CLA
4
+ # Third-party contributions licensed under DCO
5
5
 
6
6
  # Permission is hereby granted, free of charge, to any person obtaining a copy
7
7
  # of this software and associated documentation files (the "Software"), to
@@ -23,20 +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 tomllib
29
- import yaml
29
+ import pickle
30
+ from typing import IO, Any
31
+ from urllib.parse import urlparse
30
32
 
33
+ import yaml
31
34
  from click import ClickException
32
35
  from deepmerge import always_merger
33
- from functools import partial
34
- from typing import Any, IO
35
36
  from yaml import BaseLoader, Loader, YAMLError
36
37
  from yaml.constructor import ConstructorError
37
- from urllib.parse import urlparse
38
38
 
39
- 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
+
40
46
 
41
47
  # ----------------------------------------------------------------------------
42
48
  # Globals
@@ -59,9 +65,7 @@ side, and use it directly when needed. It's a hack but will do for now.
59
65
 
60
66
 
61
67
  class ConfigurationError(ClickException):
62
- """
63
- Configuration resolution or validation failed.
64
- """
68
+ """Configuration resolution or validation failed."""
65
69
 
66
70
 
67
71
  # ----------------------------------------------------------------------------
@@ -70,20 +74,17 @@ class ConfigurationError(ClickException):
70
74
 
71
75
 
72
76
  def parse_config(path: str) -> dict:
73
- """
74
- Parse configuration file.
75
- """
76
- 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":
77
81
  return parse_zensical_config(path)
78
- else:
79
- return parse_mkdocs_config(path)
82
+ return parse_mkdocs_config(path)
80
83
 
81
84
 
82
85
  def parse_zensical_config(path: str) -> dict:
83
- """
84
- Parse zensical.toml configuration file.
85
- """
86
- global _CONFIG
86
+ """Parse zensical.toml configuration file."""
87
+ global _CONFIG # noqa: PLW0603
87
88
  with open(path, "rb") as f:
88
89
  config = tomllib.load(f)
89
90
  if "project" in config:
@@ -95,11 +96,9 @@ def parse_zensical_config(path: str) -> dict:
95
96
 
96
97
 
97
98
  def parse_mkdocs_config(path: str) -> dict:
98
- """
99
- Parse mkdocs.yml configuration file.
100
- """
101
- global _CONFIG
102
- 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:
103
102
  config = _yaml_load(f)
104
103
 
105
104
  # Apply defaults and return parsed configuration
@@ -107,17 +106,20 @@ def parse_mkdocs_config(path: str) -> dict:
107
106
  return _CONFIG
108
107
 
109
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
+
110
115
  def get_theme_dir() -> str:
111
- """
112
- Return the theme directory.
113
- """
116
+ """Return the theme directory."""
114
117
  path = os.path.dirname(os.path.abspath(__file__))
115
118
  return os.path.join(path, "templates")
116
119
 
117
120
 
118
121
  def _apply_defaults(config: dict, path: str) -> dict:
119
- """
120
- Apply default settings in configuration.
122
+ """Apply default settings in configuration.
121
123
 
122
124
  Note that this is loosely based on the defaults that MkDocs sets in its own
123
125
  configuration system, which we won't port for compatibility right now, as
@@ -131,13 +133,13 @@ def _apply_defaults(config: dict, path: str) -> dict:
131
133
  raise ConfigurationError("Missing required setting: site_name")
132
134
 
133
135
  # Set site directory
134
- config.setdefault("site_dir", "site")
135
- if ".." in config.get("site_dir"):
136
+ set_default(config, "site_dir", "site", str)
137
+ if ".." in config.get("site_dir", ""):
136
138
  raise ConfigurationError("site_dir must not contain '..'")
137
139
 
138
140
  # Set docs directory
139
- config.setdefault("docs_dir", "docs")
140
- if ".." in config.get("docs_dir"):
141
+ set_default(config, "docs_dir", "docs", str)
142
+ if ".." in config.get("docs_dir", ""):
141
143
  raise ConfigurationError("docs_dir must not contain '..'")
142
144
 
143
145
  # Set defaults for core settings
@@ -155,20 +157,26 @@ def _apply_defaults(config: dict, path: str) -> dict:
155
157
  set_default(config, "edit_uri", None, str)
156
158
 
157
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
+ }
158
171
  repo_url = config.get("repo_url")
159
- if repo_url and not config.get("repo_name"):
172
+ if repo_url:
160
173
  host = urlparse(repo_url).hostname or ""
161
- if host == "github.com":
162
- config["repo_name"] = "GitHub"
163
- config["edit_uri"] = "edit/master/docs"
164
- elif host == "gitlab.com":
165
- config["repo_name"] = "GitLab"
166
- config["edit_uri"] = "edit/master/docs"
167
- elif host == "bitbucket.org":
168
- config["repo_name"] = "Bitbucket"
169
- config["edit_uri"] = "src/default/docs"
174
+ if not config.get("repo_name") and host in repo_names:
175
+ set_default(config, "repo_name", repo_names[host], str)
170
176
  elif host:
171
177
  config["repo_name"] = host.split(".")[0].title()
178
+ if host in edit_uris:
179
+ set_default(config, "edit_uri", edit_uris[host], str)
172
180
 
173
181
  # Remove trailing slash from edit_uri if present
174
182
  edit_uri = config.get("edit_uri")
@@ -176,7 +184,7 @@ def _apply_defaults(config: dict, path: str) -> dict:
176
184
  config["edit_uri"] = edit_uri.rstrip("/")
177
185
 
178
186
  # Set defaults for theme font settings
179
- theme = config.setdefault("theme", {})
187
+ theme = set_default(config, "theme", {}, dict)
180
188
  if isinstance(theme, str):
181
189
  theme = {"name": theme}
182
190
  config["theme"] = theme
@@ -212,7 +220,7 @@ def _apply_defaults(config: dict, path: str) -> dict:
212
220
  set_default(theme["font"], "code", font["code"], str)
213
221
 
214
222
  # Set defaults for theme icons
215
- icon = theme.setdefault("icon", {})
223
+ icon = set_default(theme, "icon", {}, dict)
216
224
  set_default(icon, "repo", None, str)
217
225
  set_default(icon, "annotation", None, str)
218
226
  set_default(icon, "tag", {}, dict)
@@ -242,7 +250,7 @@ def _apply_defaults(config: dict, path: str) -> dict:
242
250
  set_default(icon, "next", None, str)
243
251
 
244
252
  # Set defaults for theme admonition icons
245
- admonition = icon.setdefault("admonition", {})
253
+ admonition = set_default(icon, "admonition", {}, dict)
246
254
  set_default(admonition, "note", None, str)
247
255
  set_default(admonition, "abstract", None, str)
248
256
  set_default(admonition, "info", None, str)
@@ -277,73 +285,13 @@ def _apply_defaults(config: dict, path: str) -> dict:
277
285
  set_default(toggle, "name", None, str)
278
286
 
279
287
  # Set defaults for extra settings
280
- extra = config.setdefault("extra", {})
281
- set_default(extra, "homepage", None, str)
282
- set_default(extra, "scope", None, str)
283
- set_default(extra, "annotate", {}, dict)
284
- set_default(extra, "tags", {}, dict)
285
- set_default(extra, "generator", True, bool)
288
+ if "extra" in config and not isinstance(config["extra"], dict):
289
+ raise ConfigurationError("The 'extra' setting must be a mapping/dictionary.")
290
+ extra = set_default(config, "extra", {}, dict)
291
+
292
+ if "polyfills" in extra and not isinstance(extra["polyfills"], list):
293
+ raise ConfigurationError("The 'extra.polyfills' setting must be a list.")
286
294
  set_default(extra, "polyfills", [], list)
287
- set_default(extra, "analytics", None, dict)
288
-
289
- # Set defaults for extra analytics settings
290
- analytics = extra.get("analytics")
291
- if analytics:
292
- set_default(analytics, "provider", None, str)
293
- set_default(analytics, "property", None, str)
294
- set_default(analytics, "feedback", None, dict)
295
-
296
- # Set defaults for extra analytics feedback settings
297
- feedback = analytics.get("feedback")
298
- if feedback:
299
- set_default(feedback, "title", None, str)
300
- set_default(feedback, "ratings", [], list)
301
-
302
- # Set defaults for each rating entry
303
- ratings = feedback.setdefault("ratings", [])
304
- for entry in ratings:
305
- set_default(entry, "icon", None, str)
306
- set_default(entry, "name", None, str)
307
- set_default(entry, "data", None, str)
308
- set_default(entry, "note", None, str)
309
-
310
- # Set defaults for extra consent settings
311
- consent = extra.setdefault("consent", None)
312
- if consent:
313
- set_default(consent, "title", None, str)
314
- set_default(consent, "description", None, str)
315
- set_default(consent, "actions", [], list)
316
-
317
- # Set defaults for extra consent cookie settings
318
- cookies = consent.setdefault("cookies", {})
319
- for key, value in cookies.items():
320
- if isinstance(value, str):
321
- cookies[key] = {"name": value, "checked": False}
322
-
323
- # Set defaults for each cookie entry
324
- set_default(cookies[key], "name", None, str)
325
- set_default(cookies[key], "checked", False, bool)
326
-
327
- # Set defaults for extra social settings
328
- social = extra.setdefault("social", [])
329
- for entry in social:
330
- set_default(entry, "icon", None, str)
331
- set_default(entry, "name", None, str)
332
- set_default(entry, "link", None, str)
333
-
334
- # Set defaults for extra alternate settings
335
- alternate = extra.setdefault("alternate", [])
336
- for entry in alternate:
337
- set_default(entry, "name", None, str)
338
- set_default(entry, "link", None, str)
339
- set_default(entry, "lang", None, str)
340
-
341
- # Set defaults for extra version settings
342
- version = extra.setdefault("version", None)
343
- if version:
344
- set_default(version, "provider", None, str)
345
- set_default(version, "default", None, str)
346
- set_default(version, "alias", False, bool)
347
295
 
348
296
  # Ensure all non-existent values are all empty strings (for now)
349
297
  config["extra"] = _convert_extra(extra)
@@ -374,7 +322,7 @@ def _apply_defaults(config: dict, path: str) -> dict:
374
322
  "md_in_html": {},
375
323
  "toc": {"permalink": True},
376
324
  "pymdownx.arithmatex": {"generic": True},
377
- "pymdownx.betterem": {"smart_enable": "all"},
325
+ "pymdownx.betterem": {},
378
326
  "pymdownx.caret": {},
379
327
  "pymdownx.details": {},
380
328
  "pymdownx.emoji": {
@@ -416,30 +364,61 @@ def _apply_defaults(config: dict, path: str) -> dict:
416
364
  if isinstance(emoji.get("emoji_index"), str):
417
365
  emoji["emoji_index"] = _resolve(emoji.get("emoji_index"))
418
366
 
419
- # Tabbed extension configuration - we need to resolve the slugification
420
- # function.
367
+ # Tabbed extension configuration - resolve slugification function
421
368
  tabbed = config["mdx_configs"].get("pymdownx.tabbed", {})
422
369
  if isinstance(tabbed.get("slugify"), dict):
423
370
  object = tabbed["slugify"].get("object", "pymdownx.slugs.slugify")
424
- tabbed["slugify"] = partial(
425
- _resolve(object), tabbed["slugify"].get("kwds")
426
- )
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", {}))
378
+
379
+ # Superfences extension configuration - resolve format function
380
+ superfences = config["mdx_configs"].get("pymdownx.superfences", {})
381
+ for fence in superfences.get("custom_fences", []):
382
+ if isinstance(fence.get("format"), str):
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", {}))
427
393
 
428
394
  # Ensure the table of contents title is initialized, as it's used inside
429
395
  # the template, and the table of contents extension is always defined
430
396
  config["mdx_configs"]["toc"].setdefault("title", None)
397
+ config["mdx_configs_hash"] = _hash(mdx_configs)
431
398
 
432
399
  # Convert plugins configuration
433
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
+
434
412
  return config
435
413
 
436
414
 
437
415
  def set_default(
438
- entry: dict, key: str, default: Any, data_type: type = None
439
- ) -> None:
440
- """
441
- Set a key to a default value if it isn't set, and optionally cast it to the specified data type.
442
- """
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."""
419
+ if key in entry and entry[key] is None:
420
+ del entry[key]
421
+
443
422
  # Set the default value if the key is not present
444
423
  entry.setdefault(key, default)
445
424
 
@@ -448,13 +427,22 @@ def set_default(
448
427
  try:
449
428
  entry[key] = data_type(entry[key])
450
429
  except (ValueError, TypeError) as e:
451
- 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
433
+
434
+ # Return the resulting value
435
+ return entry[key]
436
+
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)
452
442
 
453
443
 
454
444
  def _convert_extra(data: dict | list) -> dict | list:
455
- """
456
- Recursively convert all None values in a dictionary or list to empty strings.
457
- """
445
+ """Recursively convert all None values in a dictionary or list to empty strings."""
458
446
  if isinstance(data, dict):
459
447
  # Process each key-value pair in the dictionary
460
448
  return {
@@ -463,7 +451,7 @@ def _convert_extra(data: dict | list) -> dict | list:
463
451
  else ("" if value is None else value)
464
452
  for key, value in data.items()
465
453
  }
466
- elif isinstance(data, list):
454
+ if isinstance(data, list):
467
455
  # Process each item in the list
468
456
  return [
469
457
  _convert_extra(item)
@@ -471,14 +459,11 @@ def _convert_extra(data: dict | list) -> dict | list:
471
459
  else ("" if item is None else item)
472
460
  for item in data
473
461
  ]
474
- else:
475
- return data
462
+ return data
476
463
 
477
464
 
478
- def _resolve(symbol: str):
479
- """
480
- Resolve a symbol to its corresponding Python object.
481
- """
465
+ def _resolve(symbol: str) -> Any:
466
+ """Resolve a symbol to its corresponding Python object."""
482
467
  module_path, func_name = symbol.rsplit(".", 1)
483
468
  module = importlib.import_module(module_path)
484
469
  return getattr(module, func_name)
@@ -487,17 +472,15 @@ def _resolve(symbol: str):
487
472
  # -----------------------------------------------------------------------------
488
473
 
489
474
 
490
- def _convert_nav(nav: dict) -> dict:
491
- """
492
- Convert MkDocs navigation
493
- """
475
+ def _convert_nav(nav: list) -> list:
476
+ """Convert MkDocs navigation."""
494
477
  return [_convert_nav_item(entry) for entry in nav]
495
478
 
496
479
 
497
- def _convert_nav_item(item: str | dict | list) -> dict:
498
- """
499
- Convert MkDocs shorthand navigation structure into something more manageable
500
- 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.
501
484
  """
502
485
  if isinstance(item, str):
503
486
  return {
@@ -511,19 +494,19 @@ def _convert_nav_item(item: str | dict | list) -> dict:
511
494
  }
512
495
 
513
496
  # Handle Title: URL
514
- elif isinstance(item, dict):
497
+ if isinstance(item, dict):
515
498
  for title, value in item.items():
516
499
  if isinstance(value, str):
517
500
  return {
518
501
  "title": str(title),
519
- "url": value,
502
+ "url": value.strip(),
520
503
  "canonical_url": None,
521
504
  "meta": None,
522
505
  "children": [],
523
- "is_index": _is_index(value),
506
+ "is_index": _is_index(value.strip()),
524
507
  "active": False,
525
508
  }
526
- elif isinstance(value, list):
509
+ if isinstance(value, list):
527
510
  return {
528
511
  "title": str(title),
529
512
  "url": None,
@@ -533,28 +516,25 @@ def _convert_nav_item(item: str | dict | list) -> dict:
533
516
  "is_index": False,
534
517
  "active": False,
535
518
  }
519
+ raise TypeError(f"Unknown nav item value type: {type(value)}")
536
520
 
537
521
  # Handle a list of items
538
522
  elif isinstance(item, list):
539
523
  return [_convert_nav_item(child) for child in item]
540
- else:
541
- raise ValueError(f"Unknown nav item type: {type(item)}")
524
+
525
+ raise TypeError(f"Unknown nav item type: {type(item)}")
542
526
 
543
527
 
544
528
  def _is_index(path: str) -> bool:
545
- """
546
- Returns, whether the given path points to a section index.
547
- """
548
- 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")
549
531
 
550
532
 
551
533
  # -----------------------------------------------------------------------------
552
534
 
553
535
 
554
- def _convert_extra_javascript(value: list[any]) -> list:
555
- """
556
- Ensure extra_javascript uses a structured format.
557
- """
536
+ def _convert_extra_javascript(value: list) -> list:
537
+ """Ensure extra_javascript uses a structured format."""
558
538
  for i, item in enumerate(value):
559
539
  if isinstance(item, str):
560
540
  value[i] = {
@@ -569,9 +549,7 @@ def _convert_extra_javascript(value: list[any]) -> list:
569
549
  item.setdefault("async", False)
570
550
  item.setdefault("defer", False)
571
551
  else:
572
- raise ValueError(
573
- f"Unknown extra_javascript item type: {type(item)}"
574
- )
552
+ raise TypeError(f"Unknown extra_javascript item type: {type(item)}")
575
553
 
576
554
  # Return resulting value
577
555
  return value
@@ -580,12 +558,10 @@ def _convert_extra_javascript(value: list[any]) -> list:
580
558
  # -----------------------------------------------------------------------------
581
559
 
582
560
 
583
- def _convert_markdown_extensions(value: any):
584
- """
585
- Convert Markdown extensions configuration to what Python Markdown expects.
586
- """
561
+ def _convert_markdown_extensions(value: Any) -> tuple[list[str], dict]:
562
+ """Convert Markdown extensions configuration to what Python Markdown expects."""
587
563
  markdown_extensions = ["toc", "tables"]
588
- mdx_configs = {"toc": {}, "tables": {}}
564
+ mdx_configs: dict[str, dict[str, Any]] = {"toc": {}, "tables": {}}
589
565
 
590
566
  # In case of Python Markdown Extensions, we allow to omit the necessary
591
567
  # quotes around the extension names, so we need to hoist the extensions
@@ -593,8 +569,24 @@ def _convert_markdown_extensions(value: any):
593
569
  # actually parse the configuration.
594
570
  if "pymdownx" in value:
595
571
  pymdownx = value.pop("pymdownx")
596
- for ext, config in pymdownx.items():
597
- 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
598
590
 
599
591
  # Extensions can be defined as a dict
600
592
  if isinstance(value, dict):
@@ -619,16 +611,13 @@ def _convert_markdown_extensions(value: any):
619
611
  # ----------------------------------------------------------------------------
620
612
 
621
613
 
622
- def _convert_plugins(value: any, config: dict) -> list:
623
- """
624
- Convert plugins configuration to something we can work with.
625
- """
614
+ def _convert_plugins(value: Any, config: dict) -> dict:
615
+ """Convert plugins configuration to something we can work with."""
626
616
  plugins = {}
627
617
 
628
618
  # Plugins can be defined as a dict
629
619
  if isinstance(value, dict):
630
- for name, data in value.items():
631
- plugins[name] = data
620
+ plugins.update(value)
632
621
 
633
622
  # Plugins can also be defined as a list
634
623
  else:
@@ -640,13 +629,15 @@ def _convert_plugins(value: any, config: dict) -> list:
640
629
  plugins[item] = {}
641
630
 
642
631
  # Define defaults for search plugin
643
- search = plugins.setdefault("search", {})
644
- search.setdefault("enabled", True)
645
- search.setdefault("separator", '[\\s\\-_,:!=\\[\\]()\\\\"`/]+|\\.(?!\\d)')
632
+ search = set_default(plugins, "search", {}, dict)
633
+ set_default(search, "enabled", True, bool)
634
+ set_default(
635
+ search, "separator", '[\\s\\-_,:!=\\[\\]()\\\\"`/]+|\\.(?!\\d)', str
636
+ )
646
637
 
647
638
  # Define defaults for offline plugin
648
- offline = plugins.setdefault("offline", {"enabled": False})
649
- offline.setdefault("enabled", True)
639
+ offline = set_default(plugins, "offline", {"enabled": False}, dict)
640
+ set_default(offline, "enabled", True, bool)
650
641
 
651
642
  # Ensure correct resolution of links when viewing the site from the
652
643
  # file system by disabling directory URLs
@@ -658,18 +649,20 @@ def _convert_plugins(value: any, config: dict) -> list:
658
649
  "iframe-worker" in url for url in config["extra"]["polyfills"]
659
650
  ):
660
651
  script = "https://unpkg.com/iframe-worker/shim"
661
- config["extra"]["polyfills"].append(script)
662
-
663
- # Ensure extra polyfills/shims use structured format
664
- config["extra"]["polyfills"] = _convert_extra_javascript(
665
- config["extra"]["polyfills"]
666
- )
652
+ config["extra"]["polyfills"].append(
653
+ {
654
+ "path": script,
655
+ "type": "text/javascript",
656
+ "async": False,
657
+ "defer": False,
658
+ }
659
+ )
667
660
 
668
661
  # Now, add another level of indirection, by moving all plugin configuration
669
662
  # into a `config` property, making it compatible with Material for MkDocs.
670
- for name, config in plugins.items():
671
- if not isinstance(config, dict) or "config" not in config:
672
- plugins[name] = {"config": config}
663
+ for name, data in plugins.items():
664
+ if not isinstance(data, dict) or "config" not in data:
665
+ plugins[name] = {"config": data}
673
666
 
674
667
  # Return plugins
675
668
  return plugins
@@ -681,8 +674,7 @@ def _convert_plugins(value: any, config: dict) -> list:
681
674
  def _yaml_load(
682
675
  source: IO, loader: type[BaseLoader] | None = None
683
676
  ) -> dict[str, Any]:
684
- """
685
- Load configuration file and resolve environment variables and parent files.
677
+ """Load configuration file and resolve environment variables and parent files.
686
678
 
687
679
  Note that INHERIT is only a bandaid that was introduced to allow for some
688
680
  degree of modularity, but with serious shortcomings. Zensical will use a
@@ -697,12 +689,12 @@ def _yaml_load(
697
689
  source.read()
698
690
  .replace("material.extensions", "zensical.extensions")
699
691
  .replace("materialx", "zensical.extensions"),
700
- Loader=Loader,
692
+ Loader=Loader, # noqa: S506
701
693
  )
702
694
  except YAMLError as e:
703
695
  raise ConfigurationError(
704
696
  f"Encountered an error parsing the configuration file: {e}"
705
- )
697
+ ) from e
706
698
  if config is None:
707
699
  return {}
708
700
 
@@ -716,7 +708,7 @@ def _yaml_load(
716
708
  raise ConfigurationError(
717
709
  f"Inherited config file '{relpath}' doesn't exist at '{abspath}'."
718
710
  )
719
- with open(abspath, "r") as fd:
711
+ with open(abspath, encoding="utf-8") as fd:
720
712
  parent = _yaml_load(fd, loader)
721
713
  config = always_merger.merge(parent, config)
722
714
 
@@ -724,9 +716,11 @@ def _yaml_load(
724
716
  return config
725
717
 
726
718
 
727
- def _construct_env_tag(loader: yaml.Loader, node: yaml.Node):
728
- """
729
- 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.
730
724
 
731
725
  MkDocs supports the use of !ENV to reference environment variables in YAML
732
726
  configuration files. We won't likely support this in Zensical, but for now
@@ -755,7 +749,7 @@ def _construct_env_tag(loader: yaml.Loader, node: yaml.Node):
755
749
  else:
756
750
  raise ConstructorError(
757
751
  context=f"expected a scalar or sequence node, but found {node.id}",
758
- start_mark=node.start_mark,
752
+ context_mark=node.start_mark,
759
753
  )
760
754
 
761
755
  # Resolve environment variable