maatlog 0.0.0__py3-none-any.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.
Files changed (40) hide show
  1. maatlog/__init__.py +3 -0
  2. maatlog/_myst_compat.py +80 -0
  3. maatlog/archives.py +218 -0
  4. maatlog/builders.py +72 -0
  5. maatlog/clock.py +25 -0
  6. maatlog/config.py +199 -0
  7. maatlog/directives.py +409 -0
  8. maatlog/domain.py +200 -0
  9. maatlog/errors.py +81 -0
  10. maatlog/extension.py +374 -0
  11. maatlog/feeds.py +607 -0
  12. maatlog/html_metadata.py +325 -0
  13. maatlog/images.py +74 -0
  14. maatlog/metadata.py +608 -0
  15. maatlog/model.py +94 -0
  16. maatlog/navigation.py +120 -0
  17. maatlog/outputs.py +237 -0
  18. maatlog/py.typed +1 -0
  19. maatlog/references.py +137 -0
  20. maatlog/taxonomy.py +174 -0
  21. maatlog/theme_api.py +573 -0
  22. maatlog/themes/maatlog-base/maatlog/archive.html +54 -0
  23. maatlog/themes/maatlog-base/maatlog/components/feed-links.html +10 -0
  24. maatlog/themes/maatlog-base/maatlog/components/pagination.html +18 -0
  25. maatlog/themes/maatlog-base/maatlog/components/post-card.html +32 -0
  26. maatlog/themes/maatlog-base/maatlog/components/sidebar.html +45 -0
  27. maatlog/themes/maatlog-base/maatlog/post.html +91 -0
  28. maatlog/themes/maatlog-base/maatlog-theme.toml +3 -0
  29. maatlog/themes/maatlog-base/static/maatlog.css +103 -0
  30. maatlog/themes/maatlog-base/theme.conf +3 -0
  31. maatlog/themes/maatlog-default/maatlog-theme.toml +3 -0
  32. maatlog/themes/maatlog-default/static/maatlog.css +145 -0
  33. maatlog/themes/maatlog-default/theme.conf +3 -0
  34. maatlog/urls.py +221 -0
  35. maatlog/version.py +5 -0
  36. maatlog/views.py +397 -0
  37. maatlog-0.0.0.dist-info/METADATA +146 -0
  38. maatlog-0.0.0.dist-info/RECORD +40 -0
  39. maatlog-0.0.0.dist-info/WHEEL +4 -0
  40. maatlog-0.0.0.dist-info/licenses/LICENSE +21 -0
maatlog/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .extension import setup
2
+
3
+ __all__ = ["setup"]
@@ -0,0 +1,80 @@
1
+ """Narrow compatibility adapter for typed MyST 5.1 front matter."""
2
+
3
+ from collections.abc import Callable, Mapping
4
+ from importlib import import_module
5
+ from typing import Protocol, cast
6
+
7
+ from myst_parser.config.main import TopmatterReadError, read_topmatter
8
+
9
+
10
+ class _Mark(Protocol):
11
+ line: int
12
+
13
+
14
+ class _ScalarNode(Protocol):
15
+ value: object
16
+ start_mark: _Mark
17
+
18
+
19
+ class _MappingNode(Protocol):
20
+ value: list[tuple[_ScalarNode, object]]
21
+
22
+
23
+ class _YamlModule(Protocol):
24
+ compose: Callable[[str], object | None]
25
+
26
+
27
+ class _MySTRendererModule(Protocol):
28
+ yaml: _YamlModule
29
+
30
+
31
+ def read_typed_frontmatter(source: str) -> dict[str, tuple[object, int]] | None:
32
+ """Return MyST front matter values and one-based key lines.
33
+
34
+ ``read_topmatter`` is MyST's own typed loader. MyST's renderer module owns
35
+ the YAML implementation used to turn the same payload into marked nodes;
36
+ keeping that access here prevents MaatLog from importing transitive PyYAML
37
+ directly and gives diagnostics the parser's key marks.
38
+ """
39
+
40
+ try:
41
+ topmatter = read_topmatter(source)
42
+ except TopmatterReadError:
43
+ return None
44
+ if topmatter is None:
45
+ return None
46
+
47
+ payload = _frontmatter_payload(source)
48
+ renderer = cast(_MySTRendererModule, cast(object, import_module("myst_parser.mdit_to_docutils.base")))
49
+ root = renderer.yaml.compose(payload)
50
+ key_lines = _mapping_key_lines(root)
51
+ typed = cast(Mapping[object, object], topmatter)
52
+ return {
53
+ key: (value, key_lines.get(key, 1))
54
+ for key, value in typed.items()
55
+ if isinstance(key, str) and key.startswith("maatlog-")
56
+ }
57
+
58
+
59
+ def _frontmatter_payload(source: str) -> str:
60
+ lines = source.splitlines()
61
+ if not lines or not lines[0].startswith("---"):
62
+ return ""
63
+ payload: list[str] = []
64
+ for line in lines[1:]:
65
+ if line.startswith(("---", "...")):
66
+ break
67
+ payload.append(line)
68
+ return "\n".join(payload) + ("\n" if payload else "")
69
+
70
+
71
+ def _mapping_key_lines(root: object | None) -> dict[str, int]:
72
+ if root is None or not hasattr(root, "value"):
73
+ return {}
74
+ mapping = cast(_MappingNode, root)
75
+ lines: dict[str, int] = {}
76
+ for key_node, _ in mapping.value:
77
+ if isinstance(key_node.value, str):
78
+ # The payload begins on source line 2, while parser marks are zero-based.
79
+ lines[key_node.value] = key_node.start_mark.line + 2
80
+ return lines
maatlog/archives.py ADDED
@@ -0,0 +1,218 @@
1
+ """Immutable archive page projection: filter, pagination, and docnames."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Collection, Sequence
6
+ from dataclasses import dataclass
7
+ from math import ceil
8
+ from zoneinfo import ZoneInfo
9
+
10
+ from .config import TaxonomyAxis
11
+ from .errors import Diagnostic, MaatlogBuildError
12
+ from .model import Post
13
+ from .references import archive_docname
14
+ from .taxonomy import DomainIndex
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class PostFilter:
19
+ """Filter for published posts. Empty axis means no constraint.
20
+
21
+ Within one axis, multiple IDs are OR. Across axes and month, constraints AND.
22
+ """
23
+
24
+ tags: tuple[str, ...] = ()
25
+ categories: tuple[str, ...] = ()
26
+ authors: tuple[str, ...] = ()
27
+ month: str | None = None
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class ArchiveKey:
32
+ """Identity of an archive listing (all posts, or one taxonomy/month value)."""
33
+
34
+ axis: TaxonomyAxis | None
35
+ value: str | None
36
+ label: str
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class PageSlice:
41
+ """One page of a filtered post sequence (1-origin page numbers)."""
42
+
43
+ number: int
44
+ total_pages: int
45
+ posts: tuple[Post, ...]
46
+
47
+ @property
48
+ def docname_suffix(self) -> str:
49
+ return "" if self.number == 1 else f"/page/{self.number}"
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class ArchivePage:
54
+ """One generated archive page ready for collection / rendering."""
55
+
56
+ key: ArchiveKey
57
+ docname: str
58
+ number: int
59
+ total_pages: int
60
+ posts: tuple[Post, ...]
61
+ total_posts: int
62
+
63
+
64
+ def filter_posts(
65
+ posts: Sequence[Post],
66
+ post_filter: PostFilter,
67
+ *,
68
+ timezone: ZoneInfo,
69
+ ) -> tuple[Post, ...]:
70
+ """Keep posts matching *post_filter*, preserving input order.
71
+
72
+ Same-axis IDs are OR; different axes and month are AND. An empty axis
73
+ tuple (or ``month is None``) imposes no constraint on that dimension.
74
+ """
75
+ return tuple(post for post in posts if _matches(post, post_filter, timezone=timezone))
76
+
77
+
78
+ def paginate(posts: Sequence[Post], page_size: int) -> tuple[PageSlice, ...]:
79
+ """Split *posts* into 1-origin pages of *page_size*.
80
+
81
+ Empty input yields a single empty page 1. Page 1 has no ``/page/1`` suffix.
82
+ """
83
+ if page_size < 1:
84
+ msg = f"page_size must be >= 1, got {page_size}"
85
+ raise ValueError(msg)
86
+
87
+ if not posts:
88
+ return (PageSlice(number=1, total_pages=1, posts=()),)
89
+
90
+ total_pages = ceil(len(posts) / page_size)
91
+ return tuple(
92
+ PageSlice(
93
+ number=number,
94
+ total_pages=total_pages,
95
+ posts=tuple(posts[(number - 1) * page_size : number * page_size]),
96
+ )
97
+ for number in range(1, total_pages + 1)
98
+ )
99
+
100
+
101
+ def project_archives(
102
+ index: DomainIndex,
103
+ *,
104
+ root: str,
105
+ page_size: int,
106
+ timezone: ZoneInfo,
107
+ ) -> tuple[ArchivePage, ...]:
108
+ """Project all archive pages from a published domain index.
109
+
110
+ The all-posts archive is always produced (even with zero posts). Taxonomy
111
+ and month archives are produced only when they have at least one post.
112
+ """
113
+ pages: list[ArchivePage] = []
114
+ pages.extend(
115
+ _pages_for_key(
116
+ posts=index.published,
117
+ key=ArchiveKey(axis=None, value=None, label="Posts"),
118
+ root=root,
119
+ page_size=page_size,
120
+ )
121
+ )
122
+
123
+ for axis in (TaxonomyAxis.TAG, TaxonomyAxis.CATEGORY, TaxonomyAxis.AUTHOR, TaxonomyAxis.MONTH):
124
+ axis_members = index.members.get(axis, {})
125
+ axis_labels = index.labels.get(axis, {})
126
+ for value, _slugs in axis_members.items():
127
+ if axis is TaxonomyAxis.MONTH:
128
+ post_filter = PostFilter(month=value)
129
+ elif axis is TaxonomyAxis.TAG:
130
+ post_filter = PostFilter(tags=(value,))
131
+ elif axis is TaxonomyAxis.CATEGORY:
132
+ post_filter = PostFilter(categories=(value,))
133
+ else:
134
+ post_filter = PostFilter(authors=(value,))
135
+
136
+ filtered = filter_posts(index.published, post_filter, timezone=timezone)
137
+ if not filtered:
138
+ continue
139
+ label = axis_labels.get(value, value)
140
+ pages.extend(
141
+ _pages_for_key(
142
+ posts=filtered,
143
+ key=ArchiveKey(axis=axis, value=value, label=label),
144
+ root=root,
145
+ page_size=page_size,
146
+ )
147
+ )
148
+
149
+ return tuple(pages)
150
+
151
+
152
+ def _pages_for_key(
153
+ *,
154
+ posts: Sequence[Post],
155
+ key: ArchiveKey,
156
+ root: str,
157
+ page_size: int,
158
+ ) -> list[ArchivePage]:
159
+ slices = paginate(posts, page_size)
160
+ total_posts = len(posts)
161
+ return [
162
+ ArchivePage(
163
+ key=key,
164
+ docname=archive_docname(root, key.axis, key.value, page=slice_.number),
165
+ number=slice_.number,
166
+ total_pages=slice_.total_pages,
167
+ posts=slice_.posts,
168
+ total_posts=total_posts,
169
+ )
170
+ for slice_ in slices
171
+ ]
172
+
173
+
174
+ def check_generated_docnames(
175
+ generated: Sequence[str],
176
+ *,
177
+ known_docnames: Collection[str],
178
+ ) -> None:
179
+ """Raise when projected archive docnames collide with sources or each other.
180
+
181
+ Collisions with Sphinx source / known docnames, or internal duplicates among
182
+ MaatLog pages, raise :class:`MaatlogBuildError` with
183
+ ``maatlog.generated-docname.conflict`` before any archive page is yielded or
184
+ owned outputs are committed.
185
+ """
186
+ diagnostics: list[Diagnostic] = []
187
+ seen: set[str] = set()
188
+ known = set(known_docnames)
189
+ for docname in generated:
190
+ if docname in seen or docname in known:
191
+ diagnostics.append(
192
+ Diagnostic(
193
+ code="maatlog.generated-docname.conflict",
194
+ message=f"Generated archive docname conflicts with an existing document: {docname}",
195
+ field="docname",
196
+ value=docname,
197
+ expected="unused relative docname",
198
+ )
199
+ )
200
+ seen.add(docname)
201
+ if diagnostics:
202
+ raise MaatlogBuildError(diagnostics)
203
+
204
+
205
+ def _matches(post: Post, post_filter: PostFilter, *, timezone: ZoneInfo) -> bool:
206
+ if post_filter.tags and not any(tag in post.tags for tag in post_filter.tags):
207
+ return False
208
+ if post_filter.categories and not any(cat in post.categories for cat in post_filter.categories):
209
+ return False
210
+ if post_filter.authors and not any(author in post.authors for author in post_filter.authors):
211
+ return False
212
+ if post_filter.month is not None:
213
+ if post.published_at is None:
214
+ return False
215
+ month = post.published_at.astimezone(timezone).strftime("%Y-%m")
216
+ if month != post_filter.month:
217
+ return False
218
+ return True
maatlog/builders.py ADDED
@@ -0,0 +1,72 @@
1
+ """Builder capability classification for MaatLog feature gates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+ from typing import Any
7
+
8
+ from sphinx.application import Sphinx
9
+ from sphinx.builders import Builder
10
+ from sphinx.util import logging
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ PARTIAL_SUPPORT_CODE = "maatlog.builder.partial-support"
15
+ _PARTIAL_WARNED_ATTR = "_maatlog_partial_support_warned"
16
+
17
+
18
+ class BuilderCapability(StrEnum):
19
+ """Supported feature surface for a Sphinx builder.
20
+
21
+ ``FULL_HTML`` (``html`` / ``dirhtml``): archives, Theme API validation, Atom
22
+ feeds, and HTML metadata are active.
23
+
24
+ ``DOCUMENT_ONLY`` (non-HTML formats such as ``text`` / ``latex``): post body
25
+ and post roles resolve; taxonomy roles render as inline labels; HTML-only
26
+ features are skipped without hard failures.
27
+
28
+ ``PARTIAL_HTML`` (other HTML builders such as ``singlehtml``): same document
29
+ surface as ``DOCUMENT_ONLY``, with a one-time partial-support warning.
30
+ Archives, Theme API validation, feeds, and HTML metadata stay disabled.
31
+ """
32
+
33
+ FULL_HTML = "full-html"
34
+ DOCUMENT_ONLY = "document-only"
35
+ PARTIAL_HTML = "partial-html"
36
+
37
+
38
+ def builder_capability(builder: Builder) -> BuilderCapability:
39
+ """Classify *builder* into a MaatLog capability tier."""
40
+ name = getattr(builder, "name", None)
41
+ if name in {"html", "dirhtml"}:
42
+ return BuilderCapability.FULL_HTML
43
+ if getattr(builder, "format", None) != "html":
44
+ return BuilderCapability.DOCUMENT_ONLY
45
+ return BuilderCapability.PARTIAL_HTML
46
+
47
+
48
+ def is_full_html_builder(builder: Any) -> bool:
49
+ """Return True when MaatLog's full HTML feature surface is enabled."""
50
+ return builder_capability(builder) is BuilderCapability.FULL_HTML
51
+
52
+
53
+ def warn_partial_support_once(app: Sphinx) -> None:
54
+ """Emit ``maatlog.builder.partial-support`` at most once per application.
55
+
56
+ No-op for full HTML and document-only builders. Uses an application
57
+ attribute flag so parallel or repeated handler calls stay silent.
58
+ """
59
+ if app.__dict__.get(_PARTIAL_WARNED_ATTR):
60
+ return
61
+ if builder_capability(app.builder) is not BuilderCapability.PARTIAL_HTML:
62
+ return
63
+ app.__dict__[_PARTIAL_WARNED_ATTR] = True
64
+ # Sphinx appends ``[type.subtype]`` → ``[maatlog.builder.partial-support]``.
65
+ logger.warning(
66
+ "MaatLog provides limited support for builder %r: archives, Theme API "
67
+ "validation, Atom feeds, and HTML metadata are disabled; use html or "
68
+ "dirhtml for full support",
69
+ app.builder.name,
70
+ type="maatlog",
71
+ subtype="builder.partial-support",
72
+ )
maatlog/clock.py ADDED
@@ -0,0 +1,25 @@
1
+ from collections.abc import Callable, Mapping
2
+ from datetime import UTC, datetime
3
+
4
+ from .errors import Diagnostic, MaatlogBuildError
5
+
6
+
7
+ def resolve_build_time(environ: Mapping[str, str], clock: Callable[[], datetime]) -> datetime:
8
+ source_date_epoch = environ.get("SOURCE_DATE_EPOCH")
9
+ if source_date_epoch is None:
10
+ return clock()
11
+
12
+ try:
13
+ return datetime.fromtimestamp(int(source_date_epoch), UTC)
14
+ except OverflowError, OSError, ValueError:
15
+ raise MaatlogBuildError(
16
+ [
17
+ Diagnostic(
18
+ code="maatlog.source-date-epoch.invalid",
19
+ message="Invalid SOURCE_DATE_EPOCH",
20
+ field="SOURCE_DATE_EPOCH",
21
+ value=repr(source_date_epoch),
22
+ expected="Unix seconds",
23
+ )
24
+ ]
25
+ ) from None
maatlog/config.py ADDED
@@ -0,0 +1,199 @@
1
+ from collections.abc import Mapping, Sequence
2
+ from enum import StrEnum
3
+ from re import compile as re_compile
4
+ from types import MappingProxyType
5
+ from typing import Any, cast
6
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
7
+
8
+ from pydantic import BaseModel, ConfigDict, field_validator
9
+ from sphinx.config import Config
10
+
11
+ from .errors import Diagnostic, MaatlogBuildError
12
+
13
+
14
+ class TaxonomyAxis(StrEnum):
15
+ TAG = "tag"
16
+ CATEGORY = "category"
17
+ AUTHOR = "author"
18
+ MONTH = "month"
19
+
20
+
21
+ CONFIG_VALUES = {
22
+ "maatlog_timezone": ("UTC", "env"),
23
+ "maatlog_tags": (None, "env"),
24
+ "maatlog_categories": (None, "env"),
25
+ "maatlog_authors": (None, "env"),
26
+ "maatlog_archive_docname": ("blog", "env"),
27
+ "maatlog_page_size": (10, "env"),
28
+ "maatlog_generate_feeds": (True, "html"),
29
+ "maatlog_feed_taxonomies": (("tag", "category", "author", "month"), "html"),
30
+ "maatlog_feed_limit": (20, "html"),
31
+ }
32
+
33
+ TAXONOMY_KEY_PATTERN = re_compile(r"[a-z0-9][a-z0-9._-]*\Z")
34
+
35
+
36
+ class MaatlogConfig(BaseModel):
37
+ model_config = ConfigDict(
38
+ frozen=True,
39
+ extra="forbid",
40
+ arbitrary_types_allowed=True,
41
+ )
42
+
43
+ timezone: ZoneInfo
44
+ tags: Mapping[str, str] | None
45
+ categories: Mapping[str, str] | None
46
+ authors: Mapping[str, str] | None
47
+ archive_docname: str
48
+ page_size: int
49
+ generate_feeds: bool
50
+ feed_taxonomies: tuple[TaxonomyAxis, ...]
51
+ feed_limit: int
52
+
53
+ @field_validator("tags", "categories", "authors")
54
+ @classmethod
55
+ def _freeze_taxonomy_mapping(cls, value: Mapping[str, str] | None) -> Mapping[str, str] | None:
56
+ if value is None:
57
+ return None
58
+ return MappingProxyType(dict(value))
59
+
60
+ @classmethod
61
+ def from_sphinx(cls, config: Config) -> "MaatlogConfig":
62
+ return cls.from_values({name: getattr(config, name) for name in CONFIG_VALUES})
63
+
64
+ @classmethod
65
+ def from_values(cls, values: Mapping[str, Any]) -> "MaatlogConfig":
66
+ resolved = {name: values.get(name, default) for name, (default, _) in CONFIG_VALUES.items()}
67
+ diagnostics: list[Diagnostic] = []
68
+
69
+ timezone = _validate_timezone(resolved["maatlog_timezone"], diagnostics)
70
+ tags = _validate_taxonomy_mapping("maatlog_tags", resolved["maatlog_tags"], diagnostics)
71
+ categories = _validate_taxonomy_mapping("maatlog_categories", resolved["maatlog_categories"], diagnostics)
72
+ authors = _validate_taxonomy_mapping("maatlog_authors", resolved["maatlog_authors"], diagnostics)
73
+ archive_docname = _validate_docname(resolved["maatlog_archive_docname"], diagnostics)
74
+ page_size = _validate_positive_int("maatlog_page_size", resolved["maatlog_page_size"], diagnostics)
75
+ generate_feeds = _validate_bool("maatlog_generate_feeds", resolved["maatlog_generate_feeds"], diagnostics)
76
+ feed_taxonomies = _validate_feed_taxonomies(resolved["maatlog_feed_taxonomies"], diagnostics)
77
+ feed_limit = _validate_positive_int("maatlog_feed_limit", resolved["maatlog_feed_limit"], diagnostics)
78
+
79
+ if diagnostics:
80
+ raise MaatlogBuildError(diagnostics)
81
+
82
+ assert timezone is not None
83
+ assert archive_docname is not None
84
+ assert page_size is not None
85
+ assert generate_feeds is not None
86
+ assert feed_taxonomies is not None
87
+ assert feed_limit is not None
88
+ return cls(
89
+ timezone=timezone,
90
+ tags=tags,
91
+ categories=categories,
92
+ authors=authors,
93
+ archive_docname=archive_docname,
94
+ page_size=page_size,
95
+ generate_feeds=generate_feeds,
96
+ feed_taxonomies=feed_taxonomies,
97
+ feed_limit=feed_limit,
98
+ )
99
+
100
+
101
+ def _invalid(diagnostics: list[Diagnostic], field: str, value: Any, expected: str) -> None:
102
+ diagnostics.append(
103
+ Diagnostic(
104
+ code="maatlog.config.invalid",
105
+ message=f"Invalid {field}",
106
+ field=field,
107
+ value=repr(value),
108
+ expected=expected,
109
+ )
110
+ )
111
+
112
+
113
+ def _validate_timezone(value: Any, diagnostics: list[Diagnostic]) -> ZoneInfo | None:
114
+ if not isinstance(value, str):
115
+ _invalid(diagnostics, "maatlog_timezone", value, "an IANA timezone name")
116
+ return None
117
+ try:
118
+ return ZoneInfo(value)
119
+ except ZoneInfoNotFoundError:
120
+ _invalid(diagnostics, "maatlog_timezone", value, "an IANA timezone name")
121
+ return None
122
+
123
+
124
+ def _validate_taxonomy_mapping(field: str, value: Any, diagnostics: list[Diagnostic]) -> Mapping[str, str] | None:
125
+ if value is None:
126
+ return None
127
+ if not isinstance(value, Mapping):
128
+ _invalid(diagnostics, field, value, "a mapping of strings to strings")
129
+ return None
130
+ is_valid = True
131
+ validated: dict[str, str] = {}
132
+ mapping = cast(Mapping[object, object], value)
133
+ for key, item in mapping.items():
134
+ key_is_valid = isinstance(key, str) and TAXONOMY_KEY_PATTERN.fullmatch(key) is not None
135
+ item_is_valid = isinstance(item, str) and bool(item.strip())
136
+ if not key_is_valid:
137
+ _invalid(diagnostics, field, key, "a lowercase taxonomy key")
138
+ is_valid = False
139
+ if not item_is_valid:
140
+ _invalid(diagnostics, field, item, "a non-empty display name")
141
+ is_valid = False
142
+ if key_is_valid and item_is_valid:
143
+ validated[cast(str, key)] = cast(str, item)
144
+ if not is_valid:
145
+ return None
146
+ return MappingProxyType(validated)
147
+
148
+
149
+ def _validate_docname(value: Any, diagnostics: list[Diagnostic]) -> str | None:
150
+ if not isinstance(value, str) or not value:
151
+ _invalid(diagnostics, "maatlog_archive_docname", value, "a relative Sphinx document name")
152
+ return None
153
+ segments = value.split("/")
154
+ if value.startswith("/") or value.endswith("/") or any(segment in {"", ".", ".."} for segment in segments):
155
+ _invalid(diagnostics, "maatlog_archive_docname", value, "a relative Sphinx document name")
156
+ return None
157
+ return value
158
+
159
+
160
+ def _validate_positive_int(field: str, value: Any, diagnostics: list[Diagnostic]) -> int | None:
161
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
162
+ _invalid(diagnostics, field, value, "a positive integer")
163
+ return None
164
+ return value
165
+
166
+
167
+ def _validate_bool(field: str, value: Any, diagnostics: list[Diagnostic]) -> bool | None:
168
+ if not isinstance(value, bool):
169
+ _invalid(diagnostics, field, value, "a boolean")
170
+ return None
171
+ return value
172
+
173
+
174
+ def _validate_feed_taxonomies(value: Any, diagnostics: list[Diagnostic]) -> tuple[TaxonomyAxis, ...] | None:
175
+ if not isinstance(value, (list, tuple)):
176
+ _invalid(diagnostics, "maatlog_feed_taxonomies", value, "a sequence of taxonomy axes")
177
+ return None
178
+ axes: list[TaxonomyAxis] = []
179
+ for axis in cast(Sequence[object], value):
180
+ if not isinstance(axis, str):
181
+ _invalid(diagnostics, "maatlog_feed_taxonomies", value, "tag, category, author, or month")
182
+ return None
183
+ try:
184
+ axes.append(TaxonomyAxis(axis))
185
+ except ValueError:
186
+ _invalid(diagnostics, "maatlog_feed_taxonomies", value, "tag, category, author, or month")
187
+ return None
188
+ return tuple(dict.fromkeys(axes))
189
+
190
+
191
+ def validate_config(app: Any, config: Config) -> None:
192
+ del app
193
+ MaatlogConfig.from_sphinx(config)
194
+
195
+
196
+ def register_config(app: Any) -> None:
197
+ for name, (default, rebuild) in CONFIG_VALUES.items():
198
+ app.add_config_value(name, default, rebuild)
199
+ app.connect("config-inited", validate_config)