zendoc 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.
zendoc/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ # Copyright (c) 2026 Mark Buckwell and contributors
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """zendoc: a family of independent Python-Markdown extensions for section
5
+ cross-references and bibliography/citation handling, in the spirit of
6
+ pymdown-extensions - each is its own extension, enabled separately:
7
+
8
+ - ``zendoc.headings`` - heading ids and hierarchical section numbers.
9
+ - ``zendoc.refs`` - ``\\ref{id}`` section cross-references.
10
+
11
+ See https://github.com/buckwem/zendoc-extension for documentation.
12
+ """
13
+
14
+ __version__ = "0.1.0"
15
+
16
+ __all__ = ["__version__"]
zendoc/headings.py ADDED
@@ -0,0 +1,127 @@
1
+ # Copyright (c) 2026 Mark Buckwell and contributors
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """zendoc.headings: gives every heading an id and a hierarchical section
5
+ number, recorded in a shared :class:`~zendoc.util.IdRegistry` that other
6
+ zendoc extensions (currently :mod:`zendoc.refs`) look entries up in.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import xml.etree.ElementTree as etree
13
+
14
+ from markdown import Markdown
15
+ from markdown.extensions import Extension
16
+ from markdown.extensions.toc import TocExtension
17
+ from markdown.treeprocessors import Treeprocessor
18
+
19
+ from zendoc.util import IdRegistry
20
+
21
+ HEADING_TAGS = {"h1", "h2", "h3", "h4", "h5", "h6"}
22
+
23
+
24
+ def _slugify(text: str) -> str:
25
+ """Minimal fallback slug, used only when 'toc' hasn't already assigned an
26
+ id. Enable Python-Markdown's own 'toc' extension for slugs that match the
27
+ rest of a 'toc'-rendered document exactly (unicode handling, custom
28
+ separators, etc.) - this fallback exists only so the registry still works
29
+ if a caller genuinely doesn't want a table of contents.
30
+ """
31
+ slug = re.sub(r"[^\w\s-]", "", text).strip().lower()
32
+ return re.sub(r"\s+", "-", slug)
33
+
34
+
35
+ class HeadingsTreeprocessor(Treeprocessor):
36
+ """Records every h1-h6 element's id, and its hierarchical section number,
37
+ in a shared :class:`IdRegistry`, keyed by the current document's source
38
+ name.
39
+
40
+ Numbering is per-document: h1 is a top-level counter, h2 nests under the
41
+ nearest preceding h1 ("1.1", "1.2", ...), and so on through h6 - reset
42
+ from scratch on every call, so reordering headings within a document
43
+ always produces correct numbers on the next build. A heading with an
44
+ ``unnumbered`` class (e.g. via ``# Title {: .unnumbered }``) is still
45
+ given an id but excluded from numbering - its counter position is
46
+ skipped entirely - so its registered ``number`` is ``None``.
47
+
48
+ Runs at a lower priority than 'toc' (registered at 5) so it always reads
49
+ the final id 'toc' assigned - including one already set explicitly via
50
+ 'attr_list' - rather than racing it.
51
+ """
52
+
53
+ def __init__(self, md: Markdown, registry: IdRegistry, source: str) -> None:
54
+ super().__init__(md)
55
+ self.registry = registry
56
+ self.source = source
57
+
58
+ def run(self, root: etree.Element) -> None:
59
+ self.registry.clear_source(self.source)
60
+ counters = [0] * 6
61
+ for el in root.iter():
62
+ if el.tag not in HEADING_TAGS:
63
+ continue
64
+ text = "".join(el.itertext())
65
+ heading_id = el.get("id")
66
+ if not heading_id:
67
+ heading_id = _slugify(text)
68
+ el.set("id", heading_id)
69
+
70
+ level = int(el.tag[1])
71
+ classes = (el.get("class") or "").split()
72
+ if "unnumbered" in classes:
73
+ number = None
74
+ else:
75
+ counters[level - 1] += 1
76
+ for deeper in range(level, 6):
77
+ counters[deeper] = 0
78
+ number = ".".join(str(c) for c in counters[:level])
79
+
80
+ self.registry.register(
81
+ source=self.source,
82
+ id=heading_id,
83
+ level=level,
84
+ text=text,
85
+ number=number,
86
+ )
87
+
88
+
89
+ class HeadingsExtension(Extension):
90
+ """Python-Markdown extension assigning ids and section numbers to headings."""
91
+
92
+ def __init__(self, **kwargs: object) -> None:
93
+ # Popped rather than run through Extension's own config/setConfig:
94
+ # that machinery bool-coerces any config value whose *current*
95
+ # default is None (see markdown.util.parseBoolValue), which would
96
+ # silently corrupt a real IdRegistry object passed in explicitly.
97
+ registry = kwargs.pop("registry", None)
98
+ self.registry: IdRegistry = (
99
+ registry if isinstance(registry, IdRegistry) else IdRegistry()
100
+ )
101
+ self.config = {
102
+ "source": [
103
+ "",
104
+ "Identifier for the current document (e.g. its path), used "
105
+ "to scope this document's own entries in the registry.",
106
+ ],
107
+ }
108
+ super().__init__(**kwargs)
109
+
110
+ def extendMarkdown(self, md: Markdown) -> None:
111
+ md.registerExtension(self)
112
+ # Heading ids are 'toc''s job (including respecting one 'attr_list'
113
+ # already set) - reuse it rather than re-deriving slugs here, but
114
+ # don't clobber a caller's own 'toc' config (e.g. permalink=True) if
115
+ # they've already enabled it themselves.
116
+ if "toc" not in md.treeprocessors:
117
+ TocExtension().extendMarkdown(md)
118
+ source: str = self.getConfig("source")
119
+ md.treeprocessors.register(
120
+ HeadingsTreeprocessor(md, self.registry, source),
121
+ "zendoc-headings",
122
+ 4,
123
+ )
124
+
125
+
126
+ def makeExtension(**kwargs: object) -> HeadingsExtension:
127
+ return HeadingsExtension(**kwargs)
zendoc/py.typed ADDED
File without changes
zendoc/refs.py ADDED
@@ -0,0 +1,138 @@
1
+ # Copyright (c) 2026 Mark Buckwell and contributors
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """zendoc.refs: ``\\ref{id}`` section cross-reference syntax, resolving to
5
+ the referenced heading's current section number - similar in spirit to
6
+ LaTeX's ``\\ref``. Builds on the id registry from :mod:`zendoc.headings`,
7
+ which is auto-enabled with matching defaults if not already present.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ import xml.etree.ElementTree as etree
14
+
15
+ from markdown import Markdown
16
+ from markdown.extensions import Extension
17
+ from markdown.inlinepatterns import InlineProcessor
18
+ from markdown.treeprocessors import Treeprocessor
19
+
20
+ from zendoc.headings import HeadingsExtension
21
+ from zendoc.util import IdRegistry
22
+
23
+ REF_RE = r"\\ref\{([^}\s]+)\}"
24
+
25
+
26
+ class RefInlineProcessor(InlineProcessor):
27
+ """Matches ``\\ref{id}`` and emits an unresolved placeholder ``<a>``
28
+ carrying the referenced id in a ``data-zendoc-ref`` attribute.
29
+
30
+ Registered at a low inline-pattern priority so it runs after 'backtick'
31
+ (190) and 'escape' (180) - meaning inline code spans are already stashed
32
+ out of reach by the time this pattern runs, so ``\\ref{...}`` shown as
33
+ literal example syntax inside `` `code` `` survives untouched, the same
34
+ protection fenced code blocks already get from being stashed even
35
+ earlier, during preprocessing.
36
+
37
+ The placeholder can't be resolved to a real section number here: inline
38
+ patterns run before the current document's own headings have been
39
+ numbered (see HeadingsTreeprocessor, priority 4, which runs after this
40
+ pattern's containing 'inline' treeprocessor, priority 20) - resolution
41
+ happens later, in RefResolverTreeprocessor.
42
+ """
43
+
44
+ def handleMatch( # type: ignore[override]
45
+ self, m: re.Match[str], data: str
46
+ ) -> tuple[etree.Element, int, int]:
47
+ el = etree.Element("a")
48
+ el.set("data-zendoc-ref", m.group(1))
49
+ el.set("class", "zendoc-ref")
50
+ return el, m.start(0), m.end(0)
51
+
52
+
53
+ class RefResolverTreeprocessor(Treeprocessor):
54
+ """Resolves the placeholder ``<a data-zendoc-ref="id">`` elements left by
55
+ :class:`RefInlineProcessor` to the referenced heading's section number,
56
+ once the current document's own headings have been numbered.
57
+
58
+ Runs at a lower priority than 'zendoc-headings' (4) so every heading in
59
+ *this* document - including one defined further down the page than
60
+ where it's referenced - is already registered by the time resolution
61
+ happens. A reference to a heading in a document not yet processed in
62
+ this build (e.g. a later page in a multi-page site) can't be resolved
63
+ yet either; both cases fall back to `unresolved`, the same way an
64
+ undefined LaTeX \\ref shows "??" until a later compilation pass.
65
+ """
66
+
67
+ def __init__(self, md: Markdown, registry: IdRegistry, unresolved: str = "??") -> None:
68
+ super().__init__(md)
69
+ self.registry = registry
70
+ self.unresolved = unresolved
71
+
72
+ def run(self, root: etree.Element) -> None:
73
+ for el in root.iter("a"):
74
+ ref_id = el.get("data-zendoc-ref")
75
+ if ref_id is None:
76
+ continue
77
+ del el.attrib["data-zendoc-ref"]
78
+ record = self.registry.get(ref_id)
79
+ if record is None or record.number is None:
80
+ el.text = self.unresolved
81
+ el.set("class", "zendoc-ref zendoc-ref-unresolved")
82
+ if record is not None:
83
+ # Known heading, just unnumbered (e.g. {: .unnumbered }) -
84
+ # still a valid link target, unlike a genuinely unknown id.
85
+ el.set("href", f"#{ref_id}")
86
+ else:
87
+ el.text = record.number
88
+ el.set("href", f"#{ref_id}")
89
+
90
+
91
+ class RefsExtension(Extension):
92
+ """Python-Markdown extension providing the ``\\ref{id}`` syntax."""
93
+
94
+ def __init__(self, **kwargs: object) -> None:
95
+ # See HeadingsExtension for why this is popped rather than run
96
+ # through Extension's own config/setConfig machinery. None here
97
+ # means "discover the registry from a sibling HeadingsExtension",
98
+ # not "use an empty registry" - that distinction can't be made once
99
+ # a value has round-tripped through setConfig.
100
+ registry = kwargs.pop("registry", None)
101
+ self.registry: IdRegistry | None = registry if isinstance(registry, IdRegistry) else None
102
+ self.config = {
103
+ "unresolved": [
104
+ "??",
105
+ "Text rendered by \\ref{id} when id doesn't resolve to a "
106
+ "numbered heading - unknown id, or a heading marked "
107
+ "unnumbered.",
108
+ ],
109
+ }
110
+ super().__init__(**kwargs)
111
+
112
+ def extendMarkdown(self, md: Markdown) -> None:
113
+ md.registerExtension(self)
114
+ registry = self.registry
115
+ if registry is None:
116
+ headings_ext = next(
117
+ (ext for ext in md.registeredExtensions if isinstance(ext, HeadingsExtension)),
118
+ None,
119
+ )
120
+ if headings_ext is None:
121
+ headings_ext = HeadingsExtension()
122
+ headings_ext.extendMarkdown(md)
123
+ registry = headings_ext.registry
124
+ unresolved: str = self.getConfig("unresolved")
125
+ md.inlinePatterns.register(
126
+ RefInlineProcessor(REF_RE, md),
127
+ "zendoc-ref",
128
+ 45,
129
+ )
130
+ md.treeprocessors.register(
131
+ RefResolverTreeprocessor(md, registry, unresolved),
132
+ "zendoc-ref-resolver",
133
+ 2,
134
+ )
135
+
136
+
137
+ def makeExtension(**kwargs: object) -> RefsExtension:
138
+ return RefsExtension(**kwargs)
zendoc/util.py ADDED
@@ -0,0 +1,63 @@
1
+ # Copyright (c) 2026 Mark Buckwell and contributors
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Shared data structures used by more than one zendoc extension.
5
+
6
+ A single :class:`IdRegistry` instance is meant to be shared across every
7
+ source document in a build (one extension instance per document, e.g. one
8
+ :class:`~zendoc.headings.HeadingsExtension` call per page), so that
9
+ :mod:`zendoc.refs` (and a future citation extension) can resolve an id to
10
+ the document, heading, and current section number that defines it,
11
+ regardless of which document is currently being converted.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class HeadingRecord:
21
+ source: str
22
+ id: str
23
+ level: int
24
+ text: str
25
+ number: str | None = None
26
+
27
+
28
+ class DuplicateIdError(ValueError):
29
+ """Raised when the same id is registered from two different sources."""
30
+
31
+
32
+ class IdRegistry:
33
+ def __init__(self) -> None:
34
+ self._headings: dict[str, HeadingRecord] = {}
35
+
36
+ def register(
37
+ self, source: str, id: str, level: int, text: str, number: str | None = None
38
+ ) -> None:
39
+ existing = self._headings.get(id)
40
+ if existing is not None and existing.source != source:
41
+ raise DuplicateIdError(
42
+ f"heading id {id!r} is already registered from "
43
+ f"{existing.source!r}; cannot also register it from {source!r}"
44
+ )
45
+ self._headings[id] = HeadingRecord(
46
+ source=source, id=id, level=level, text=text, number=number
47
+ )
48
+
49
+ def get(self, id: str) -> HeadingRecord | None:
50
+ return self._headings.get(id)
51
+
52
+ def __contains__(self, id: str) -> bool:
53
+ return id in self._headings
54
+
55
+ def clear_source(self, source: str) -> None:
56
+ """Drops every entry previously registered from source.
57
+
58
+ Needed so re-converting the same document (e.g. a live-reload dev
59
+ server) can't leave a stale id behind after a heading's text - and
60
+ therefore its slug - changes between builds.
61
+ """
62
+ for stale_id in [k for k, v in self._headings.items() if v.source == source]:
63
+ del self._headings[stale_id]
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: zendoc
3
+ Version: 0.1.0
4
+ Summary: A family of Python-Markdown extensions for section cross-references and bibliography/citation handling
5
+ Project-URL: Homepage, https://github.com/buckwem/zendoc-extension
6
+ Project-URL: Issues, https://github.com/buckwem/zendoc-extension/issues
7
+ Author: Mark Buckwell
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: markdown>=3.4
17
+ Provides-Extra: dev
18
+ Requires-Dist: mypy; extra == 'dev'
19
+ Requires-Dist: pytest; extra == 'dev'
20
+ Requires-Dist: pytest-cov; extra == 'dev'
21
+ Requires-Dist: ruff; extra == 'dev'
22
+ Requires-Dist: types-markdown; extra == 'dev'
23
+ Provides-Extra: docs
24
+ Requires-Dist: zensical; extra == 'docs'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # zendoc
28
+
29
+ A family of [Python-Markdown](https://python-markdown.github.io/) extensions
30
+ for section cross-references and bibliography/citation handling, in the
31
+ spirit of [pymdown-extensions](https://facelessuser.github.io/pymdown-extensions/):
32
+ each extension is independent and enabled separately. Factored out of
33
+ [zendoc-template](https://github.com/buckwem/zendoc-template) so it can be
34
+ installed and reused independently of that template.
35
+
36
+ > **Status:** early - `zendoc.headings` and `zendoc.refs` are implemented;
37
+ > citation handling isn't yet. See
38
+ > [zendoc-template#25](https://github.com/buckwem/zendoc-template/issues/25)
39
+ > for the tracking issue and scope.
40
+
41
+ **[Full documentation](https://buckwem.github.io/zendoc-extension/)**
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ pip install zendoc
47
+ ```
48
+
49
+ ## Extensions
50
+
51
+ | Extension | Description |
52
+ |---|---|
53
+ | [`zendoc.headings`](https://buckwem.github.io/zendoc-extension/extensions/headings/) | Gives every heading an id and a hierarchical section number ("1", "1.1", "1.2", "2", ...). |
54
+ | [`zendoc.refs`](https://buckwem.github.io/zendoc-extension/extensions/refs/) | `\ref{id}` section cross-references, resolving to the target's current number - similar in spirit to LaTeX's `\ref`. |
55
+
56
+ ```python
57
+ import markdown
58
+
59
+ html = markdown.markdown(text, extensions=["zendoc.headings", "zendoc.refs"])
60
+ ```
61
+
62
+ ```md
63
+ # Introduction {: #intro }
64
+
65
+ See \ref{intro} for background.
66
+ ```
67
+
68
+ `\ref{intro}` resolves to a link reading `1` - the heading's current
69
+ section number - and stays correct if sections are reordered, since
70
+ numbering is recomputed on every conversion. See the
71
+ [docs](https://buckwem.github.io/zendoc-extension/) for options, multi-page
72
+ registry sharing, and full syntax details.
73
+
74
+ ## Development
75
+
76
+ ```bash
77
+ python -m venv .venv
78
+ source .venv/bin/activate
79
+ pip install -e ".[dev]"
80
+ pytest
81
+ ```
82
+
83
+ To build the documentation locally:
84
+
85
+ ```bash
86
+ pip install -e ".[docs]"
87
+ zensical serve
88
+ ```
89
+
90
+ ## License
91
+
92
+ MIT - see [LICENSE](LICENSE).
@@ -0,0 +1,10 @@
1
+ zendoc/__init__.py,sha256=dzzQyw5WajnTEbrH2wyK4e4rXtpngpM95xqwgg3khNA,549
2
+ zendoc/headings.py,sha256=do5GimUOf5fLAoYL3B26BOaPH5vLG7t8Tz39v1I27Pk,4911
3
+ zendoc/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ zendoc/refs.py,sha256=Ad6clNFnU0_tWNC8-ixfQ196Xj3471Pnbf5Sr_C1uwY,5650
5
+ zendoc/util.py,sha256=WNrbKomO3gh5khiwpWd0t0XGV0CE5i5DHZDGgYj_2EI,2169
6
+ zendoc-0.1.0.dist-info/METADATA,sha256=t1vwqp-iGcInDcBMhiT_IgHt_07JT0moZVl-PRbPkqE,2941
7
+ zendoc-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
8
+ zendoc-0.1.0.dist-info/entry_points.txt,sha256=cyid0A-qxc432LeM9dCznLYDYf1ldgswCpbDEt_2ShY,114
9
+ zendoc-0.1.0.dist-info/licenses/LICENSE,sha256=qk54ayQf53MkX-HvpfOn2dFrvVVImmpvdYaKjPv2B-U,1087
10
+ zendoc-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [markdown.extensions]
2
+ zendoc.headings = zendoc.headings:HeadingsExtension
3
+ zendoc.refs = zendoc.refs:RefsExtension
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mark Buckwell and contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.