prodockit 0.1.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.
- prodockit/__init__.py +39 -0
- prodockit/_zensical.py +260 -0
- prodockit/citations.py +207 -0
- prodockit/glossary.py +206 -0
- prodockit/headings.py +223 -0
- prodockit/pdf/__init__.py +38 -0
- prodockit/pdf/build.py +261 -0
- prodockit/pdf/cli.py +52 -0
- prodockit/pdf/config.py +189 -0
- prodockit/pdf/css.py +626 -0
- prodockit/pdf/html.py +499 -0
- prodockit/pdf/icons.py +171 -0
- prodockit/pdf/lua.py +256 -0
- prodockit/pdf/mermaid.py +97 -0
- prodockit/py.typed +0 -0
- prodockit/refs.py +158 -0
- prodockit/settings.py +66 -0
- prodockit/util.py +272 -0
- prodockit/wordcount.py +38 -0
- prodockit/zensical_macros.py +251 -0
- prodockit-0.1.0.dist-info/METADATA +148 -0
- prodockit-0.1.0.dist-info/RECORD +25 -0
- prodockit-0.1.0.dist-info/WHEEL +4 -0
- prodockit-0.1.0.dist-info/entry_points.txt +8 -0
- prodockit-0.1.0.dist-info/licenses/LICENSE +21 -0
prodockit/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Copyright (c) 2026 Mark Buckwell and contributors
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""prodockit: a family of extensions for Zensical (https://zensical.org/) -
|
|
5
|
+
the pieces professional and academic documentation commonly needs that
|
|
6
|
+
Zensical doesn't provide out of the box, each usable independently:
|
|
7
|
+
|
|
8
|
+
- ``prodockit.headings`` - heading ids and hierarchical section numbers.
|
|
9
|
+
- ``prodockit.refs`` - ``\\ref{id}`` section cross-references.
|
|
10
|
+
- ``prodockit.citations`` - define a source once, cite it by key anywhere with
|
|
11
|
+
``\\cite{id}``.
|
|
12
|
+
- ``prodockit.glossary`` - define a term once, insert it by id anywhere with
|
|
13
|
+
``\\gls{id}``.
|
|
14
|
+
- ``prodockit.pdf`` - build a standalone PDF from your Zensical site via
|
|
15
|
+
Pandoc and WeasyPrint, the kind of downloadable, submittable document
|
|
16
|
+
professional/academic reports typically need alongside the website
|
|
17
|
+
itself. Run ``prodockit pdf`` from your project root - no Python required,
|
|
18
|
+
it reads the same ``zensical.toml`` your site already has.
|
|
19
|
+
- ``prodockit.zensical_macros`` - Jinja variables/macros for Zensical's own
|
|
20
|
+
macros plugin: a site-wide word count, the git-detected repository URL,
|
|
21
|
+
chapter/appendix numbering that continues across pages, and reference/
|
|
22
|
+
acronym/glossary spacing that matches ``prodockit.pdf``'s own PDF output.
|
|
23
|
+
|
|
24
|
+
``prodockit.headings``/``prodockit.refs``/``prodockit.citations``/``prodockit.glossary``
|
|
25
|
+
are Python-Markdown extensions, in the spirit of pymdown-extensions - enable
|
|
26
|
+
one in `zensical.toml` the same way as a built-in or pymdownx extension.
|
|
27
|
+
Zensical's per-page rendering context is detected automatically where it's
|
|
28
|
+
useful (see their own cross-page registry sharing). ``prodockit.pdf`` is a
|
|
29
|
+
command-line tool instead (there's no ``markdown.extensions`` entry point
|
|
30
|
+
for it - a PDF build pipeline isn't a Markdown syntax extension).
|
|
31
|
+
``prodockit.zensical_macros`` is a plain ``define_env()`` module for Zensical's
|
|
32
|
+
macros plugin's own ``modules`` config, not a Markdown extension either.
|
|
33
|
+
|
|
34
|
+
See https://buckwem.github.io/prodockit-extensions/ for documentation.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
__version__ = "0.10.0"
|
|
38
|
+
|
|
39
|
+
__all__ = ["__version__"]
|
prodockit/_zensical.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# Copyright (c) 2026 Mark Buckwell and contributors
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Internal helpers for Zensical-aware, cross-page state sharing.
|
|
5
|
+
|
|
6
|
+
Not part of prodockit's public API - shared by prodockit.headings,
|
|
7
|
+
prodockit.citations, and prodockit.glossary, all of which face the same problem:
|
|
8
|
+
Zensical builds each page with its own fresh ``Markdown`` instance, so a
|
|
9
|
+
plain per-instance default registry can never see another page's entries.
|
|
10
|
+
``nav_pages``/``preseed_attr_from_nav`` (used by prodockit.citations and
|
|
11
|
+
prodockit.glossary) additionally address pages being built in a single,
|
|
12
|
+
one-shot pass: a forward reference to a page not yet built can't resolve
|
|
13
|
+
without pre-scanning ahead of time.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Protocol, TypeVar
|
|
21
|
+
|
|
22
|
+
from markdown import Markdown
|
|
23
|
+
|
|
24
|
+
T = TypeVar("T")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def page_source(md: Markdown) -> str | None:
|
|
28
|
+
"""Returns the current page's path if running under Zensical's per-page
|
|
29
|
+
``render()``, else None.
|
|
30
|
+
|
|
31
|
+
Zensical stashes a ``Page`` (with a stable, per-page path) on every
|
|
32
|
+
``Markdown`` instance it builds, via its own ``ContextPreprocessor`` -
|
|
33
|
+
used as a best-effort default for ``source`` when the caller hasn't set
|
|
34
|
+
one explicitly, since without it every page would silently share the
|
|
35
|
+
same default source (``""``), each page wiping the previous one's
|
|
36
|
+
registry entries on every render. Returns None (falling back to
|
|
37
|
+
ordinary behaviour) under any other Python-Markdown tool, or if
|
|
38
|
+
Zensical isn't installed.
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
from zensical.extensions.context import ContextPreprocessor
|
|
42
|
+
except ImportError:
|
|
43
|
+
return None
|
|
44
|
+
context = ContextPreprocessor.from_markdown(md)
|
|
45
|
+
if context is None or context.page is None:
|
|
46
|
+
return None
|
|
47
|
+
return context.page.path
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def share(md: Markdown, attr: str, value: T) -> T:
|
|
51
|
+
"""Order-independent same-page sharing between two prodockit extensions
|
|
52
|
+
that both want the same piece of state (e.g. prodockit.headings and
|
|
53
|
+
prodockit.refs sharing an IdRegistry): whichever extension's
|
|
54
|
+
extendMarkdown() runs first claims `value` by stashing it on `md` under
|
|
55
|
+
`attr`; whichever runs second reuses what's already there - regardless
|
|
56
|
+
of which order the two extensions were listed in (Zensical's own
|
|
57
|
+
TOML-to-extension-list conversion doesn't preserve list order, so this
|
|
58
|
+
can't be assumed).
|
|
59
|
+
"""
|
|
60
|
+
existing = getattr(md, attr, None)
|
|
61
|
+
if existing is not None:
|
|
62
|
+
return existing # type: ignore[no-any-return]
|
|
63
|
+
setattr(md, attr, value)
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def nav_pages() -> tuple[str, list[str]] | None:
|
|
68
|
+
"""Returns (docs_dir, [nav markdown file paths]) from Zensical's own
|
|
69
|
+
already-parsed build configuration, if running under Zensical with that
|
|
70
|
+
configuration actually populated (i.e. mid-build - `zensical.config.get_config()`
|
|
71
|
+
returns an empty/absent config otherwise, e.g. under a plain script
|
|
72
|
+
import). Returns None in that case, or if Zensical isn't installed.
|
|
73
|
+
|
|
74
|
+
Used to pre-scan every page's raw text for something (currently
|
|
75
|
+
prodockit.citations' citation definitions) before any single page has
|
|
76
|
+
actually been converted - needed because Zensical's `render()` builds
|
|
77
|
+
one page a time, in one pass, so a page late in nav order (e.g. a
|
|
78
|
+
references page kept as an appendix) hasn't been touched yet at the
|
|
79
|
+
point an early page might already want to resolve something defined
|
|
80
|
+
there.
|
|
81
|
+
"""
|
|
82
|
+
try:
|
|
83
|
+
from zensical.config import get_config
|
|
84
|
+
except ImportError:
|
|
85
|
+
return None
|
|
86
|
+
config = get_config()
|
|
87
|
+
if not config:
|
|
88
|
+
return None
|
|
89
|
+
docs_dir = config.get("docs_dir")
|
|
90
|
+
nav = config.get("nav")
|
|
91
|
+
if not docs_dir or not nav:
|
|
92
|
+
return None
|
|
93
|
+
return str(docs_dir), _flatten_nav(nav)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _flatten_nav(items: object) -> list[str]:
|
|
97
|
+
"""Flattens Zensical's normalised nav structure (a list of
|
|
98
|
+
{"url": ..., "children": [...]} dicts, possibly nested) into a plain
|
|
99
|
+
list of markdown file paths, in nav order."""
|
|
100
|
+
paths: list[str] = []
|
|
101
|
+
if not isinstance(items, list):
|
|
102
|
+
return paths
|
|
103
|
+
for item in items:
|
|
104
|
+
if isinstance(item, dict):
|
|
105
|
+
url = item.get("url")
|
|
106
|
+
if url:
|
|
107
|
+
paths.append(url)
|
|
108
|
+
paths.extend(_flatten_nav(item.get("children")))
|
|
109
|
+
elif isinstance(item, list):
|
|
110
|
+
paths.extend(_flatten_nav(item))
|
|
111
|
+
return paths
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class _Preseedable(Protocol):
|
|
115
|
+
def preseed(self, source: str, id: str, text: str) -> None: ...
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
_ATTR_RE = re.compile(r"\{:\s*([^}]+?)\s*\}")
|
|
119
|
+
_ID_RE = re.compile(r"#([\w-]+)")
|
|
120
|
+
_FENCE_RE = re.compile(
|
|
121
|
+
r"^[ \t]*```.*?^[ \t]*```[ \t]*$|^[ \t]*~~~.*?^[ \t]*~~~[ \t]*$",
|
|
122
|
+
re.MULTILINE | re.DOTALL,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _strip_fences(text: str) -> str:
|
|
127
|
+
"""Blanks out fenced code blocks before a raw-text regex scan, so a
|
|
128
|
+
documentation page showing a definition syntax as a *literal example*
|
|
129
|
+
inside a fenced code block isn't mistaken for a real definition by
|
|
130
|
+
preseed_attr_from_nav (which, unlike the real per-page treeprocessor,
|
|
131
|
+
scans raw text directly rather than the parsed, fence-aware
|
|
132
|
+
Python-Markdown tree)."""
|
|
133
|
+
return _FENCE_RE.sub("", text)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _front_matter_flag(text: str, key: str) -> bool:
|
|
137
|
+
"""True if raw file text's YAML front matter sets ``key: true``. Used by
|
|
138
|
+
prescan_headings() to detect an appendix-flagged page ahead of that page
|
|
139
|
+
actually being converted."""
|
|
140
|
+
if not text.startswith("---"):
|
|
141
|
+
return False
|
|
142
|
+
parts = text.split("---", 2)
|
|
143
|
+
if len(parts) < 3:
|
|
144
|
+
return False
|
|
145
|
+
return bool(
|
|
146
|
+
re.search(rf"^{re.escape(key)}:\s*true\s*$", parts[1], re.MULTILINE | re.IGNORECASE)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _count_top_level_headings(text: str) -> int:
|
|
151
|
+
"""Counts top-level (single ``#``) ATX headings in raw markdown text,
|
|
152
|
+
skipping fenced code blocks, HTML comments, and headings tagged
|
|
153
|
+
``{.unnumbered}`` - used by prescan_headings() to work out how many
|
|
154
|
+
numbered h1s appear on a page before any page has actually been
|
|
155
|
+
converted. Line-based rather than a single regex (unlike _strip_fences()
|
|
156
|
+
above) since it also needs to track HTML comments, not just fences, in
|
|
157
|
+
one pass."""
|
|
158
|
+
count = 0
|
|
159
|
+
in_fence = False
|
|
160
|
+
in_comment = False
|
|
161
|
+
for line in text.splitlines():
|
|
162
|
+
stripped = line.strip()
|
|
163
|
+
if not in_comment and (stripped.startswith("```") or stripped.startswith("~~~")):
|
|
164
|
+
in_fence = not in_fence
|
|
165
|
+
continue
|
|
166
|
+
if in_fence:
|
|
167
|
+
continue
|
|
168
|
+
if not in_comment and "<!--" in stripped:
|
|
169
|
+
in_comment = True
|
|
170
|
+
if in_comment:
|
|
171
|
+
if "-->" in stripped:
|
|
172
|
+
in_comment = False
|
|
173
|
+
continue
|
|
174
|
+
if re.match(r"^#\s+\S", line) and ".unnumbered" not in line:
|
|
175
|
+
count += 1
|
|
176
|
+
return count
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def prescan_headings(appendix_attr: str) -> tuple[dict[str, int], dict[str, str]] | None:
|
|
180
|
+
"""Returns ``(start_counts, appendix_letters)``, both keyed by
|
|
181
|
+
nav-relative page path, by pre-scanning every page in the current
|
|
182
|
+
Zensical build's nav - the same "before any page has actually been
|
|
183
|
+
converted" pre-scan preseed_attr_from_nav does for citation/glossary
|
|
184
|
+
definitions, applied to heading counts instead.
|
|
185
|
+
|
|
186
|
+
``start_counts[page]`` is how many numbered h1s appear on every earlier
|
|
187
|
+
nav page, for prodockit.headings' "continuous" numbering mode to seed this
|
|
188
|
+
page's own h1 counter with - so numbering continues seamlessly from one
|
|
189
|
+
page to the next instead of resetting per page. A page whose front
|
|
190
|
+
matter sets `appendix_attr` is skipped entirely for this count (it
|
|
191
|
+
doesn't consume a number from the sequence) and instead gets a
|
|
192
|
+
sequential letter in ``appendix_letters`` - "A" for the first such page
|
|
193
|
+
in nav order, "B" for the second, and so on.
|
|
194
|
+
|
|
195
|
+
Returns None outside a Zensical build (mirrors nav_pages()).
|
|
196
|
+
"""
|
|
197
|
+
located = nav_pages()
|
|
198
|
+
if located is None:
|
|
199
|
+
return None
|
|
200
|
+
docs_dir, pages = located
|
|
201
|
+
start_counts: dict[str, int] = {}
|
|
202
|
+
appendix_letters: dict[str, str] = {}
|
|
203
|
+
running_total = 0
|
|
204
|
+
next_letter_index = 0
|
|
205
|
+
for rel_path in pages:
|
|
206
|
+
try:
|
|
207
|
+
text = (Path(docs_dir) / rel_path).read_text(encoding="utf-8")
|
|
208
|
+
except OSError:
|
|
209
|
+
continue
|
|
210
|
+
if _front_matter_flag(text, appendix_attr):
|
|
211
|
+
next_letter_index += 1
|
|
212
|
+
appendix_letters[rel_path] = chr(ord("A") + next_letter_index - 1)
|
|
213
|
+
continue
|
|
214
|
+
start_counts[rel_path] = running_total
|
|
215
|
+
running_total += _count_top_level_headings(text)
|
|
216
|
+
return start_counts, appendix_letters
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def preseed_attr_from_nav(registry: _Preseedable, attr_name: str) -> None:
|
|
220
|
+
"""Pre-scans every page in the current Zensical build's nav for a
|
|
221
|
+
``{: #id <attr_name>="..." }`` attr_list definition, provisionally
|
|
222
|
+
registering each one (via ``registry.preseed``) before any page has
|
|
223
|
+
actually been converted.
|
|
224
|
+
|
|
225
|
+
Fixes the classic "cited/referenced before defined" ordering problem: a
|
|
226
|
+
source is usually cited/referenced from an early chapter but defined on
|
|
227
|
+
a references/acronyms/glossary page kept at the end of nav, which -
|
|
228
|
+
without this - is a forward reference to a page `zensical build`'s
|
|
229
|
+
single, one-shot process hasn't rendered yet (unlike `zensical serve`'s
|
|
230
|
+
live-reload, which eventually rebuilds every page at least once). Reads
|
|
231
|
+
raw file text directly rather than waiting for Python-Markdown to parse
|
|
232
|
+
it - safe here because the id/value are already literal attr_list
|
|
233
|
+
attribute values, unlike e.g. prodockit.headings' section numbers, which
|
|
234
|
+
genuinely depend on running the real Python-Markdown pipeline to
|
|
235
|
+
compute. Skips fenced code blocks, so a documentation page showing this
|
|
236
|
+
exact attr_list syntax as a literal example doesn't get mistaken for a
|
|
237
|
+
real definition.
|
|
238
|
+
|
|
239
|
+
Used by prodockit.citations (`attr_name="data-cite-text"`) and
|
|
240
|
+
prodockit.glossary (`attr_name="data-term"`) - both need the identical
|
|
241
|
+
scan, differing only in which attribute they're looking for and which
|
|
242
|
+
registry they feed.
|
|
243
|
+
"""
|
|
244
|
+
located = nav_pages()
|
|
245
|
+
if located is None:
|
|
246
|
+
return
|
|
247
|
+
docs_dir, pages = located
|
|
248
|
+
value_re = re.compile(attr_name + r'="([^"]*)"')
|
|
249
|
+
for rel_path in pages:
|
|
250
|
+
try:
|
|
251
|
+
text = (Path(docs_dir) / rel_path).read_text(encoding="utf-8")
|
|
252
|
+
except OSError:
|
|
253
|
+
continue
|
|
254
|
+
text = _strip_fences(text)
|
|
255
|
+
for attr_match in _ATTR_RE.finditer(text):
|
|
256
|
+
attrs = attr_match.group(1)
|
|
257
|
+
id_match = _ID_RE.search(attrs)
|
|
258
|
+
value_match = value_re.search(attrs)
|
|
259
|
+
if id_match and value_match:
|
|
260
|
+
registry.preseed(rel_path, id_match.group(1), value_match.group(1))
|
prodockit/citations.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Copyright (c) 2026 Mark Buckwell and contributors
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""prodockit.citations: define a citation source once, cite it by key anywhere.
|
|
5
|
+
|
|
6
|
+
Mark a source's paragraph (or any block) with an id and a short display
|
|
7
|
+
text via ``attr_list``:
|
|
8
|
+
|
|
9
|
+
Skoulikari, A. (2023) *Learning Git*. Sebastopol, CA: O'Reilly Media.
|
|
10
|
+
{: #skou2023 .reference data-cite-text="Skoulikari, 2023" }
|
|
11
|
+
|
|
12
|
+
then cite it from anywhere in the build with ``\\cite{skou2023}``, which
|
|
13
|
+
resolves to a linked, bracketed citation: ``[Skoulikari, 2023]``. Multiple
|
|
14
|
+
keys in one citation (``\\cite{skou2023,chacon2014}``) render as
|
|
15
|
+
``[Skoulikari, 2023; Chacon and Straub, 2014]``.
|
|
16
|
+
|
|
17
|
+
Unlike prodockit.headings/prodockit.refs, defining and citing are bundled into one
|
|
18
|
+
extension here rather than split in two: a definition is useless without a
|
|
19
|
+
place to cite it, and a citation is meaningless without something defined
|
|
20
|
+
to point at - there's no independently useful "just defining" half the way
|
|
21
|
+
prodockit.headings' ids/numbers are useful even without prodockit.refs.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
import xml.etree.ElementTree as etree
|
|
28
|
+
|
|
29
|
+
from markdown import Markdown
|
|
30
|
+
from markdown.extensions import Extension
|
|
31
|
+
from markdown.inlinepatterns import InlineProcessor
|
|
32
|
+
from markdown.treeprocessors import Treeprocessor
|
|
33
|
+
|
|
34
|
+
from prodockit._zensical import page_source, preseed_attr_from_nav, share
|
|
35
|
+
from prodockit.util import CitationRegistry, cross_page_href
|
|
36
|
+
|
|
37
|
+
CITE_RE = r"\\cite\{([^}]+)\}"
|
|
38
|
+
|
|
39
|
+
# Shared across every page of a single Zensical build - see prodockit._zensical
|
|
40
|
+
# and CitationsExtension.extendMarkdown. Never touched unless Zensical's
|
|
41
|
+
# per-page context is actually detected.
|
|
42
|
+
_ZENSICAL_SHARED_REGISTRY = CitationRegistry()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CitationDefTreeprocessor(Treeprocessor):
|
|
46
|
+
"""Registers every element carrying a ``data-cite-text`` attribute
|
|
47
|
+
(typically set via ``attr_list``, e.g. ``{: #skou2023 data-cite-text="Skoulikari, 2023" }``)
|
|
48
|
+
in a shared :class:`~prodockit.util.CitationRegistry`, keyed by the current
|
|
49
|
+
document's source name, then strips the attribute - it's internal
|
|
50
|
+
bookkeeping, not meant to leak into the rendered HTML.
|
|
51
|
+
|
|
52
|
+
Runs at a lower priority than 'attr_list' (registered at 8) so it always
|
|
53
|
+
sees the id/attribute attr_list assigned, rather than racing it.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self, md: Markdown, registry: CitationRegistry, source: str, strict: bool = True
|
|
58
|
+
) -> None:
|
|
59
|
+
super().__init__(md)
|
|
60
|
+
self.registry = registry
|
|
61
|
+
self.source = source
|
|
62
|
+
self.strict = strict
|
|
63
|
+
|
|
64
|
+
def run(self, root: etree.Element) -> None:
|
|
65
|
+
self.registry.clear_source(self.source)
|
|
66
|
+
for el in root.iter():
|
|
67
|
+
text = el.get("data-cite-text")
|
|
68
|
+
if text is None:
|
|
69
|
+
continue
|
|
70
|
+
del el.attrib["data-cite-text"]
|
|
71
|
+
citation_id = el.get("id")
|
|
72
|
+
if not citation_id:
|
|
73
|
+
continue
|
|
74
|
+
self.registry.register(
|
|
75
|
+
source=self.source,
|
|
76
|
+
id=citation_id,
|
|
77
|
+
text=text,
|
|
78
|
+
strict=self.strict,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class CiteInlineProcessor(InlineProcessor):
|
|
83
|
+
"""Matches ``\\cite{id}`` or ``\\cite{id1,id2,...}`` and emits an
|
|
84
|
+
unresolved placeholder ``<span>`` carrying the raw, comma-separated keys
|
|
85
|
+
in a ``data-prodockit-cite`` attribute.
|
|
86
|
+
|
|
87
|
+
Registered at a low inline-pattern priority so it runs after 'backtick'
|
|
88
|
+
(190) and 'escape' (180) - meaning inline code spans are already stashed
|
|
89
|
+
out of reach by the time this pattern runs, so ``\\cite{...}`` shown as
|
|
90
|
+
literal example syntax survives untouched, the same protection fenced
|
|
91
|
+
code blocks get from being stashed even earlier, during preprocessing.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def handleMatch( # type: ignore[override]
|
|
95
|
+
self, m: re.Match[str], data: str
|
|
96
|
+
) -> tuple[etree.Element, int, int]:
|
|
97
|
+
el = etree.Element("span")
|
|
98
|
+
el.set("data-prodockit-cite", m.group(1))
|
|
99
|
+
return el, m.start(0), m.end(0)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class CiteResolverTreeprocessor(Treeprocessor):
|
|
103
|
+
"""Resolves the placeholder ``<span data-prodockit-cite="...">`` elements
|
|
104
|
+
left by :class:`CiteInlineProcessor` into a bracketed citation, once the
|
|
105
|
+
current document's own citation definitions have been registered.
|
|
106
|
+
|
|
107
|
+
Runs at a lower priority than 'prodockit-citations-def' so a citation can
|
|
108
|
+
reference a source defined further down the same document - or on a
|
|
109
|
+
page not yet built in a Zensical multi-page site, which instead falls
|
|
110
|
+
back to `unresolved` for that key, the same way prodockit.refs does.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self, md: Markdown, registry: CitationRegistry, source: str, unresolved: str = "?"
|
|
115
|
+
) -> None:
|
|
116
|
+
super().__init__(md)
|
|
117
|
+
self.registry = registry
|
|
118
|
+
self.source = source
|
|
119
|
+
self.unresolved = unresolved
|
|
120
|
+
|
|
121
|
+
def run(self, root: etree.Element) -> None:
|
|
122
|
+
for el in root.iter("span"):
|
|
123
|
+
raw_keys = el.get("data-prodockit-cite")
|
|
124
|
+
if raw_keys is None:
|
|
125
|
+
continue
|
|
126
|
+
del el.attrib["data-prodockit-cite"]
|
|
127
|
+
el.set("class", "prodockit-cite")
|
|
128
|
+
keys = [key.strip() for key in raw_keys.split(",") if key.strip()]
|
|
129
|
+
el.text = "["
|
|
130
|
+
last = len(keys) - 1
|
|
131
|
+
for i, key in enumerate(keys):
|
|
132
|
+
record = self.registry.get(key)
|
|
133
|
+
a = etree.SubElement(el, "a")
|
|
134
|
+
if record is None:
|
|
135
|
+
a.text = self.unresolved
|
|
136
|
+
a.set("class", "prodockit-cite-unresolved")
|
|
137
|
+
else:
|
|
138
|
+
a.text = record.text
|
|
139
|
+
a.set("href", cross_page_href(record.source, self.source, key))
|
|
140
|
+
a.tail = "]" if i == last else "; "
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class CitationsExtension(Extension):
|
|
144
|
+
"""Python-Markdown extension providing citation-key definitions and the
|
|
145
|
+
``\\cite{id}`` syntax."""
|
|
146
|
+
|
|
147
|
+
def __init__(self, **kwargs: object) -> None:
|
|
148
|
+
# See prodockit.headings.HeadingsExtension for why this is popped
|
|
149
|
+
# rather than run through Extension's own config/setConfig
|
|
150
|
+
# machinery.
|
|
151
|
+
registry = kwargs.pop("registry", None)
|
|
152
|
+
self._registry_explicit = isinstance(registry, CitationRegistry)
|
|
153
|
+
self.registry: CitationRegistry = (
|
|
154
|
+
registry if isinstance(registry, CitationRegistry) else CitationRegistry()
|
|
155
|
+
)
|
|
156
|
+
self.config = {
|
|
157
|
+
"source": [
|
|
158
|
+
"",
|
|
159
|
+
"Identifier for the current document (e.g. its path), used "
|
|
160
|
+
"to scope this document's own citation definitions in the "
|
|
161
|
+
"registry.",
|
|
162
|
+
],
|
|
163
|
+
"unresolved": [
|
|
164
|
+
"?",
|
|
165
|
+
"Text rendered for a \\cite{id} key that doesn't resolve to "
|
|
166
|
+
"a definition.",
|
|
167
|
+
],
|
|
168
|
+
}
|
|
169
|
+
super().__init__(**kwargs)
|
|
170
|
+
|
|
171
|
+
def extendMarkdown(self, md: Markdown) -> None:
|
|
172
|
+
md.registerExtension(self)
|
|
173
|
+
source: str = self.getConfig("source")
|
|
174
|
+
registry = self.registry
|
|
175
|
+
strict = True
|
|
176
|
+
# Only kick in when the caller hasn't configured anything
|
|
177
|
+
# themselves - see HeadingsExtension.extendMarkdown for the same
|
|
178
|
+
# pattern and rationale.
|
|
179
|
+
if not self._registry_explicit and not source:
|
|
180
|
+
detected_source = page_source(md)
|
|
181
|
+
if detected_source is not None:
|
|
182
|
+
source = detected_source
|
|
183
|
+
registry = _ZENSICAL_SHARED_REGISTRY
|
|
184
|
+
strict = False
|
|
185
|
+
preseed_attr_from_nav(registry, "data-cite-text")
|
|
186
|
+
registry = share(md, "prodockit_citation_registry", registry)
|
|
187
|
+
self.registry = registry
|
|
188
|
+
unresolved: str = self.getConfig("unresolved")
|
|
189
|
+
md.treeprocessors.register(
|
|
190
|
+
CitationDefTreeprocessor(md, registry, source, strict=strict),
|
|
191
|
+
"prodockit-citations-def",
|
|
192
|
+
6,
|
|
193
|
+
)
|
|
194
|
+
md.inlinePatterns.register(
|
|
195
|
+
CiteInlineProcessor(CITE_RE, md),
|
|
196
|
+
"prodockit-cite",
|
|
197
|
+
44,
|
|
198
|
+
)
|
|
199
|
+
md.treeprocessors.register(
|
|
200
|
+
CiteResolverTreeprocessor(md, registry, source, unresolved),
|
|
201
|
+
"prodockit-cite-resolver",
|
|
202
|
+
1,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def makeExtension(**kwargs: object) -> CitationsExtension:
|
|
207
|
+
return CitationsExtension(**kwargs)
|