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 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)