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.
- maatlog/__init__.py +3 -0
- maatlog/_myst_compat.py +80 -0
- maatlog/archives.py +218 -0
- maatlog/builders.py +72 -0
- maatlog/clock.py +25 -0
- maatlog/config.py +199 -0
- maatlog/directives.py +409 -0
- maatlog/domain.py +200 -0
- maatlog/errors.py +81 -0
- maatlog/extension.py +374 -0
- maatlog/feeds.py +607 -0
- maatlog/html_metadata.py +325 -0
- maatlog/images.py +74 -0
- maatlog/metadata.py +608 -0
- maatlog/model.py +94 -0
- maatlog/navigation.py +120 -0
- maatlog/outputs.py +237 -0
- maatlog/py.typed +1 -0
- maatlog/references.py +137 -0
- maatlog/taxonomy.py +174 -0
- maatlog/theme_api.py +573 -0
- maatlog/themes/maatlog-base/maatlog/archive.html +54 -0
- maatlog/themes/maatlog-base/maatlog/components/feed-links.html +10 -0
- maatlog/themes/maatlog-base/maatlog/components/pagination.html +18 -0
- maatlog/themes/maatlog-base/maatlog/components/post-card.html +32 -0
- maatlog/themes/maatlog-base/maatlog/components/sidebar.html +45 -0
- maatlog/themes/maatlog-base/maatlog/post.html +91 -0
- maatlog/themes/maatlog-base/maatlog-theme.toml +3 -0
- maatlog/themes/maatlog-base/static/maatlog.css +103 -0
- maatlog/themes/maatlog-base/theme.conf +3 -0
- maatlog/themes/maatlog-default/maatlog-theme.toml +3 -0
- maatlog/themes/maatlog-default/static/maatlog.css +145 -0
- maatlog/themes/maatlog-default/theme.conf +3 -0
- maatlog/urls.py +221 -0
- maatlog/version.py +5 -0
- maatlog/views.py +397 -0
- maatlog-0.0.0.dist-info/METADATA +146 -0
- maatlog-0.0.0.dist-info/RECORD +40 -0
- maatlog-0.0.0.dist-info/WHEEL +4 -0
- maatlog-0.0.0.dist-info/licenses/LICENSE +21 -0
maatlog/__init__.py
ADDED
maatlog/_myst_compat.py
ADDED
|
@@ -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)
|