zendoc 0.1.0__tar.gz
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-0.1.0/.github/workflows/ci.yml +33 -0
- zendoc-0.1.0/.github/workflows/docs.yml +32 -0
- zendoc-0.1.0/.github/workflows/publish.yml +33 -0
- zendoc-0.1.0/.gitignore +15 -0
- zendoc-0.1.0/LICENSE +21 -0
- zendoc-0.1.0/PKG-INFO +92 -0
- zendoc-0.1.0/README.md +66 -0
- zendoc-0.1.0/docs/about/changelog.md +13 -0
- zendoc-0.1.0/docs/about/license.md +5 -0
- zendoc-0.1.0/docs/extensions/headings.md +104 -0
- zendoc-0.1.0/docs/extensions/refs.md +125 -0
- zendoc-0.1.0/docs/index.md +51 -0
- zendoc-0.1.0/docs/installation.md +60 -0
- zendoc-0.1.0/pyproject.toml +57 -0
- zendoc-0.1.0/src/zendoc/__init__.py +16 -0
- zendoc-0.1.0/src/zendoc/headings.py +127 -0
- zendoc-0.1.0/src/zendoc/py.typed +0 -0
- zendoc-0.1.0/src/zendoc/refs.py +138 -0
- zendoc-0.1.0/src/zendoc/util.py +63 -0
- zendoc-0.1.0/tests/test_headings.py +104 -0
- zendoc-0.1.0/tests/test_refs.py +88 -0
- zendoc-0.1.0/zensical.toml +68 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Copyright (c) 2026 Mark Buckwell and contributors
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
name: CI
|
|
5
|
+
on:
|
|
6
|
+
push:
|
|
7
|
+
branches: [main]
|
|
8
|
+
pull_request:
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v5
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: ${{ matrix.python-version }}
|
|
20
|
+
- run: pip install -e ".[dev]"
|
|
21
|
+
- run: ruff check .
|
|
22
|
+
- run: mypy src
|
|
23
|
+
- run: pytest --cov=zendoc
|
|
24
|
+
|
|
25
|
+
docs:
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/checkout@v5
|
|
29
|
+
- uses: actions/setup-python@v5
|
|
30
|
+
with:
|
|
31
|
+
python-version: "3.x"
|
|
32
|
+
- run: pip install -e ".[docs]"
|
|
33
|
+
- run: zensical build --clean --strict
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Copyright (c) 2026 Mark Buckwell and contributors
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
name: Documentation
|
|
5
|
+
on:
|
|
6
|
+
push:
|
|
7
|
+
branches:
|
|
8
|
+
- main
|
|
9
|
+
workflow_dispatch:
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
pages: write
|
|
13
|
+
id-token: write
|
|
14
|
+
jobs:
|
|
15
|
+
deploy:
|
|
16
|
+
environment:
|
|
17
|
+
name: github-pages
|
|
18
|
+
url: ${{ steps.deployment.outputs.page_url }}
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/configure-pages@v5
|
|
22
|
+
- uses: actions/checkout@v5
|
|
23
|
+
- uses: actions/setup-python@v5
|
|
24
|
+
with:
|
|
25
|
+
python-version: "3.x"
|
|
26
|
+
- run: pip install -e ".[docs]"
|
|
27
|
+
- run: zensical build --clean --strict
|
|
28
|
+
- uses: actions/upload-pages-artifact@v4
|
|
29
|
+
with:
|
|
30
|
+
path: site
|
|
31
|
+
- uses: actions/deploy-pages@v4
|
|
32
|
+
id: deployment
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Copyright (c) 2026 Mark Buckwell and contributors
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
name: Publish to PyPI
|
|
5
|
+
on:
|
|
6
|
+
release:
|
|
7
|
+
types: [published]
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v5
|
|
13
|
+
- uses: actions/setup-python@v5
|
|
14
|
+
with:
|
|
15
|
+
python-version: "3.x"
|
|
16
|
+
- run: pip install build
|
|
17
|
+
- run: python -m build
|
|
18
|
+
- uses: actions/upload-artifact@v4
|
|
19
|
+
with:
|
|
20
|
+
name: dist
|
|
21
|
+
path: dist/
|
|
22
|
+
publish:
|
|
23
|
+
needs: build
|
|
24
|
+
runs-on: ubuntu-latest
|
|
25
|
+
environment: pypi
|
|
26
|
+
permissions:
|
|
27
|
+
id-token: write # required for PyPI trusted publishing
|
|
28
|
+
steps:
|
|
29
|
+
- uses: actions/download-artifact@v4
|
|
30
|
+
with:
|
|
31
|
+
name: dist
|
|
32
|
+
path: dist/
|
|
33
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
zendoc-0.1.0/.gitignore
ADDED
zendoc-0.1.0/LICENSE
ADDED
|
@@ -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.
|
zendoc-0.1.0/PKG-INFO
ADDED
|
@@ -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).
|
zendoc-0.1.0/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# zendoc
|
|
2
|
+
|
|
3
|
+
A family of [Python-Markdown](https://python-markdown.github.io/) extensions
|
|
4
|
+
for section cross-references and bibliography/citation handling, in the
|
|
5
|
+
spirit of [pymdown-extensions](https://facelessuser.github.io/pymdown-extensions/):
|
|
6
|
+
each extension is independent and enabled separately. Factored out of
|
|
7
|
+
[zendoc-template](https://github.com/buckwem/zendoc-template) so it can be
|
|
8
|
+
installed and reused independently of that template.
|
|
9
|
+
|
|
10
|
+
> **Status:** early - `zendoc.headings` and `zendoc.refs` are implemented;
|
|
11
|
+
> citation handling isn't yet. See
|
|
12
|
+
> [zendoc-template#25](https://github.com/buckwem/zendoc-template/issues/25)
|
|
13
|
+
> for the tracking issue and scope.
|
|
14
|
+
|
|
15
|
+
**[Full documentation](https://buckwem.github.io/zendoc-extension/)**
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install zendoc
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Extensions
|
|
24
|
+
|
|
25
|
+
| Extension | Description |
|
|
26
|
+
|---|---|
|
|
27
|
+
| [`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", ...). |
|
|
28
|
+
| [`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`. |
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import markdown
|
|
32
|
+
|
|
33
|
+
html = markdown.markdown(text, extensions=["zendoc.headings", "zendoc.refs"])
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```md
|
|
37
|
+
# Introduction {: #intro }
|
|
38
|
+
|
|
39
|
+
See \ref{intro} for background.
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`\ref{intro}` resolves to a link reading `1` - the heading's current
|
|
43
|
+
section number - and stays correct if sections are reordered, since
|
|
44
|
+
numbering is recomputed on every conversion. See the
|
|
45
|
+
[docs](https://buckwem.github.io/zendoc-extension/) for options, multi-page
|
|
46
|
+
registry sharing, and full syntax details.
|
|
47
|
+
|
|
48
|
+
## Development
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
python -m venv .venv
|
|
52
|
+
source .venv/bin/activate
|
|
53
|
+
pip install -e ".[dev]"
|
|
54
|
+
pytest
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
To build the documentation locally:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install -e ".[docs]"
|
|
61
|
+
zensical serve
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
MIT - see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Release Notes
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-07-14)
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- `zendoc.headings`: heading ids and hierarchical section numbering,
|
|
8
|
+
backed by a shared `IdRegistry`.
|
|
9
|
+
- `zendoc.refs`: `\ref{id}` section cross-references, resolving to the
|
|
10
|
+
target's current section number, including forward references within a
|
|
11
|
+
document and across a shared registry.
|
|
12
|
+
- Documentation site built with Zensical, published at
|
|
13
|
+
[buckwem.github.io/zendoc-extension](https://buckwem.github.io/zendoc-extension/).
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Headings
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`zendoc.headings` gives every heading in a document an `id` and a
|
|
6
|
+
hierarchical section number, and records both - along with the heading's
|
|
7
|
+
text and level - in a shared registry keyed by a document "source" name.
|
|
8
|
+
|
|
9
|
+
It exists to be a foundation other zendoc extensions build on:
|
|
10
|
+
[zendoc.refs](refs.md) resolves `\ref{id}` by looking an id up in exactly
|
|
11
|
+
this registry. You can also enable `zendoc.headings` on its own if you just
|
|
12
|
+
want ids/numbers on your headings without cross-references.
|
|
13
|
+
|
|
14
|
+
Numbering is per-document and hierarchical: an `h1` is a top-level counter
|
|
15
|
+
("1", "2", ...), an `h2` nests under the nearest preceding `h1` ("1.1",
|
|
16
|
+
"1.2", ...), and so on down through `h6`. Numbers are recomputed from
|
|
17
|
+
scratch on every conversion, so reordering headings always produces correct
|
|
18
|
+
numbers on the next build - there's no stored/stale numbering state.
|
|
19
|
+
|
|
20
|
+
## Example
|
|
21
|
+
|
|
22
|
+
```md
|
|
23
|
+
# Introduction
|
|
24
|
+
|
|
25
|
+
## Background
|
|
26
|
+
|
|
27
|
+
## Scope
|
|
28
|
+
|
|
29
|
+
# Method
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
produces (numbers shown for illustration - `zendoc.headings` doesn't render
|
|
33
|
+
numbers into the heading text itself, only into the registry; see
|
|
34
|
+
[zendoc.refs](refs.md) for rendering a number inline):
|
|
35
|
+
|
|
36
|
+
| Heading | id | number |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| Introduction | `introduction` | `1` |
|
|
39
|
+
| Background | `background` | `1.1` |
|
|
40
|
+
| Scope | `scope` | `1.2` |
|
|
41
|
+
| Method | `method` | `2` |
|
|
42
|
+
|
|
43
|
+
## Ids
|
|
44
|
+
|
|
45
|
+
An id comes from one of, in order of precedence:
|
|
46
|
+
|
|
47
|
+
1. An explicit id set via
|
|
48
|
+
[`attr_list`](https://python-markdown.github.io/extensions/attr_list/),
|
|
49
|
+
e.g. `# Introduction {: #custom-id }`.
|
|
50
|
+
2. Python-Markdown's own [`toc`](https://python-markdown.github.io/extensions/toc/)
|
|
51
|
+
extension, which `zendoc.headings` enables automatically (with its
|
|
52
|
+
defaults) if you haven't already enabled it yourself - so if you *have*
|
|
53
|
+
configured `toc` (e.g. with `permalink: true`), that configuration is
|
|
54
|
+
left untouched and reused.
|
|
55
|
+
3. A minimal built-in slugify fallback, used only if `toc` is somehow not
|
|
56
|
+
registered at all (this should not normally happen, since
|
|
57
|
+
`zendoc.headings` enables it).
|
|
58
|
+
|
|
59
|
+
## Unnumbered headings
|
|
60
|
+
|
|
61
|
+
A heading with an `unnumbered` class - e.g. a cover page or title slide -
|
|
62
|
+
still gets an id, but is skipped when computing section numbers, so it
|
|
63
|
+
doesn't consume a counter position:
|
|
64
|
+
|
|
65
|
+
```md
|
|
66
|
+
# Cover Page {: .unnumbered }
|
|
67
|
+
|
|
68
|
+
# Introduction
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`Introduction` above is still numbered `1`, as if `Cover Page` weren't
|
|
72
|
+
there at all.
|
|
73
|
+
|
|
74
|
+
## Options
|
|
75
|
+
|
|
76
|
+
| Option | Type | Default | Description |
|
|
77
|
+
|---|---|---|---|
|
|
78
|
+
| `source` | `str` | `""` | Identifier for the current document (e.g. its file path). Used to scope this document's entries in the registry, and to safely clear/replace them on a rebuild of the same document. |
|
|
79
|
+
| `registry` | `IdRegistry \| None` | a new `IdRegistry()` | Share one registry across multiple documents/conversions - see below. Passed as a constructor keyword, not a string-based config value (Python-Markdown's config system can't carry arbitrary Python objects safely). |
|
|
80
|
+
|
|
81
|
+
## Sharing a registry across a multi-page build
|
|
82
|
+
|
|
83
|
+
To resolve cross-page references, every page in a build needs to write into
|
|
84
|
+
- and read from - the *same* `IdRegistry` instance. Construct one and pass
|
|
85
|
+
it to every page's extension instance, along with that page's own `source`:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
import markdown
|
|
89
|
+
from zendoc.headings import HeadingsExtension
|
|
90
|
+
from zendoc.util import IdRegistry
|
|
91
|
+
|
|
92
|
+
registry = IdRegistry()
|
|
93
|
+
|
|
94
|
+
for path, text in pages:
|
|
95
|
+
html = markdown.markdown(
|
|
96
|
+
text,
|
|
97
|
+
extensions=[HeadingsExtension(registry=registry, source=path)],
|
|
98
|
+
)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
A duplicate id registered from two *different* sources raises
|
|
102
|
+
`zendoc.util.DuplicateIdError` - re-converting the *same* source (e.g. a
|
|
103
|
+
live-reload dev server) is safe and expected; its previous entries are
|
|
104
|
+
cleared first.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Refs
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`zendoc.refs` adds a `\ref{id}` cross-reference syntax - similar in spirit
|
|
6
|
+
to LaTeX's `\ref` - that resolves to the *current* section number of the
|
|
7
|
+
heading with that id:
|
|
8
|
+
|
|
9
|
+
```md
|
|
10
|
+
# Introduction {: #intro }
|
|
11
|
+
|
|
12
|
+
See \ref{intro} for background.
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
renders `\ref{intro}` as a link to `#intro` reading `1`. Because the number
|
|
16
|
+
is looked up fresh on every conversion (see [zendoc.headings](headings.md)),
|
|
17
|
+
it stays correct even if sections are added, removed, or reordered - you
|
|
18
|
+
never have to manually renumber a cross-reference.
|
|
19
|
+
|
|
20
|
+
`zendoc.refs` depends on the id/number registry that
|
|
21
|
+
[zendoc.headings](headings.md) builds. If you enable `zendoc.refs` on its
|
|
22
|
+
own, it transparently enables `zendoc.headings` for you with matching
|
|
23
|
+
defaults, so a single document works with no extra configuration. If you
|
|
24
|
+
list both explicitly, list `zendoc.headings` first - see
|
|
25
|
+
[Multi-page builds](#multi-page-builds) below.
|
|
26
|
+
|
|
27
|
+
## Syntax
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
\ref{<id>}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`<id>` is the target heading's id - either one you set explicitly via
|
|
34
|
+
[`attr_list`](https://python-markdown.github.io/extensions/attr_list/)
|
|
35
|
+
(`# Introduction {: #intro }`), or the one
|
|
36
|
+
[`toc`](https://python-markdown.github.io/extensions/toc/) derived
|
|
37
|
+
automatically from the heading text (see [zendoc.headings](headings.md#ids)
|
|
38
|
+
for the exact precedence).
|
|
39
|
+
|
|
40
|
+
`\ref{...}` is recognised the same way Python-Markdown's own inline syntax
|
|
41
|
+
is - meaning it's protected inside inline code spans and fenced code
|
|
42
|
+
blocks, so it's safe to show as a literal example:
|
|
43
|
+
|
|
44
|
+
````md
|
|
45
|
+
Type `\ref{intro}` to reference a section.
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
\ref{intro}
|
|
49
|
+
```
|
|
50
|
+
````
|
|
51
|
+
|
|
52
|
+
Neither of the two shown above is resolved; both render the literal text.
|
|
53
|
+
|
|
54
|
+
## Forward references
|
|
55
|
+
|
|
56
|
+
A reference to a heading defined *later* in the same document resolves
|
|
57
|
+
correctly:
|
|
58
|
+
|
|
59
|
+
```md
|
|
60
|
+
See \ref{background} below.
|
|
61
|
+
|
|
62
|
+
## Background {: #background }
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Unresolved references
|
|
66
|
+
|
|
67
|
+
`\ref{id}` renders the `unresolved` marker (`??` by default) instead of a
|
|
68
|
+
number when:
|
|
69
|
+
|
|
70
|
+
- `id` doesn't exist in the registry at all - e.g. a typo, or a reference
|
|
71
|
+
to a heading in a page that hasn't been converted yet in a multi-page
|
|
72
|
+
build (the same way an undefined LaTeX `\ref` shows `??` until a later
|
|
73
|
+
compilation pass).
|
|
74
|
+
- `id` exists but belongs to a heading marked `unnumbered` (see
|
|
75
|
+
[zendoc.headings](headings.md#unnumbered-headings)) - it's still a valid
|
|
76
|
+
link target in this case, just without a number to show.
|
|
77
|
+
|
|
78
|
+
```md
|
|
79
|
+
# Cover Page {: .unnumbered }
|
|
80
|
+
|
|
81
|
+
See \ref{cover-page}.
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
renders `\ref{cover-page}` as `??`, linked to `#cover-page`.
|
|
85
|
+
|
|
86
|
+
## Options
|
|
87
|
+
|
|
88
|
+
| Option | Type | Default | Description |
|
|
89
|
+
|---|---|---|---|
|
|
90
|
+
| `unresolved` | `str` | `"??"` | Text rendered when `id` doesn't resolve to a numbered heading. |
|
|
91
|
+
| `registry` | `IdRegistry \| None` | discovered from a sibling `zendoc.headings`, or a new one | Share one registry across multiple documents - see below. Passed as a constructor keyword, not a string-based config value. |
|
|
92
|
+
|
|
93
|
+
## Multi-page builds
|
|
94
|
+
|
|
95
|
+
To resolve cross-page references, give `zendoc.headings` and `zendoc.refs`
|
|
96
|
+
the *same* `IdRegistry` on every page, converting pages in the order
|
|
97
|
+
cross-references should become resolvable in (a reference to a page not
|
|
98
|
+
yet converted resolves to `unresolved`, as above):
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
import markdown
|
|
102
|
+
from zendoc.headings import HeadingsExtension
|
|
103
|
+
from zendoc.refs import RefsExtension
|
|
104
|
+
from zendoc.util import IdRegistry
|
|
105
|
+
|
|
106
|
+
registry = IdRegistry()
|
|
107
|
+
|
|
108
|
+
for path, text in pages:
|
|
109
|
+
html = markdown.markdown(
|
|
110
|
+
text,
|
|
111
|
+
extensions=[
|
|
112
|
+
HeadingsExtension(registry=registry, source=path),
|
|
113
|
+
RefsExtension(registry=registry),
|
|
114
|
+
],
|
|
115
|
+
)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
If you enable both extensions by name instead (e.g. from a
|
|
119
|
+
`mkdocs.yml`/`zensical.toml`-style config, where you can't pass a shared
|
|
120
|
+
`IdRegistry` object directly), list `zendoc.headings` before `zendoc.refs`:
|
|
121
|
+
`zendoc.refs` looks for an already-registered `zendoc.headings` instance on
|
|
122
|
+
the current `Markdown` object and reuses its registry automatically, so a
|
|
123
|
+
per-page site-generator integration (a plugin owning one shared registry
|
|
124
|
+
across the whole build) is the way to get cross-page resolution without
|
|
125
|
+
constructing extension instances by hand.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# zendoc
|
|
2
|
+
|
|
3
|
+
zendoc is a family of [Python-Markdown](https://python-markdown.github.io/)
|
|
4
|
+
extensions for section cross-references and bibliography/citation handling,
|
|
5
|
+
in the spirit of [pymdown-extensions](https://facelessuser.github.io/pymdown-extensions/):
|
|
6
|
+
each extension is independent and enabled separately, so you only pay for
|
|
7
|
+
what you use.
|
|
8
|
+
|
|
9
|
+
zendoc was factored out of
|
|
10
|
+
[zendoc-template](https://github.com/buckwem/zendoc-template) so it can be
|
|
11
|
+
installed and reused independently of that template - see
|
|
12
|
+
[zendoc-template#25](https://github.com/buckwem/zendoc-template/issues/25)
|
|
13
|
+
for the tracking issue and original motivation.
|
|
14
|
+
|
|
15
|
+
## Extensions
|
|
16
|
+
|
|
17
|
+
| Extension | Description |
|
|
18
|
+
|---|---|
|
|
19
|
+
| [zendoc.headings](extensions/headings.md) | Gives every heading an id and a hierarchical section number ("1", "1.1", "1.2", "2", ...). |
|
|
20
|
+
| [zendoc.refs](extensions/refs.md) | `\ref{id}` section cross-references, resolving to the target's current number - similar in spirit to LaTeX's `\ref`. |
|
|
21
|
+
|
|
22
|
+
Bibliography/citation handling is planned but not implemented yet.
|
|
23
|
+
|
|
24
|
+
## Quick example
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import markdown
|
|
28
|
+
|
|
29
|
+
html = markdown.markdown(
|
|
30
|
+
"""
|
|
31
|
+
# Introduction
|
|
32
|
+
|
|
33
|
+
See \\ref{background} for context.
|
|
34
|
+
|
|
35
|
+
## Background
|
|
36
|
+
|
|
37
|
+
...
|
|
38
|
+
""",
|
|
39
|
+
extensions=["zendoc.headings", "zendoc.refs"],
|
|
40
|
+
)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`\ref{background}` resolves to `1.1` - the current section number of the
|
|
44
|
+
heading it points to - and stays correct if headings are reordered, because
|
|
45
|
+
numbering is recomputed fresh on every conversion.
|
|
46
|
+
|
|
47
|
+
## Status
|
|
48
|
+
|
|
49
|
+
Early. `zendoc.headings` and `zendoc.refs` are implemented and tested;
|
|
50
|
+
citation-key management is not yet built. See the
|
|
51
|
+
[Release Notes](about/changelog.md) for what's landed so far.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Installation
|
|
2
|
+
|
|
3
|
+
## From PyPI
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install zendoc
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
`zendoc` depends only on [`Markdown`](https://python-markdown.github.io/)
|
|
10
|
+
(>= 3.4) - no other runtime dependencies.
|
|
11
|
+
|
|
12
|
+
## Enabling an extension
|
|
13
|
+
|
|
14
|
+
Each zendoc extension is registered as a standard Python-Markdown extension
|
|
15
|
+
under the `markdown.extensions` entry point group, so it can be enabled by
|
|
16
|
+
name, the same way you'd enable a built-in extension like `toc` or a
|
|
17
|
+
`pymdownx` one:
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import markdown
|
|
21
|
+
|
|
22
|
+
html = markdown.markdown(text, extensions=["zendoc.headings", "zendoc.refs"])
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or in a `mkdocs.yml`/`zensical.toml`-style config:
|
|
26
|
+
|
|
27
|
+
```yaml
|
|
28
|
+
markdown_extensions:
|
|
29
|
+
- zendoc.headings
|
|
30
|
+
- zendoc.refs
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```toml
|
|
34
|
+
[project.markdown_extensions.zendoc.headings]
|
|
35
|
+
[project.markdown_extensions.zendoc.refs]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
See each extension's own page for its options and for how to share one
|
|
39
|
+
registry across multiple pages of a site build:
|
|
40
|
+
|
|
41
|
+
- [zendoc.headings](extensions/headings.md)
|
|
42
|
+
- [zendoc.refs](extensions/refs.md)
|
|
43
|
+
|
|
44
|
+
## Development install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
git clone https://github.com/buckwem/zendoc-extension
|
|
48
|
+
cd zendoc-extension
|
|
49
|
+
python -m venv .venv
|
|
50
|
+
source .venv/bin/activate
|
|
51
|
+
pip install -e ".[dev]"
|
|
52
|
+
pytest
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
To build these docs locally:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install -e ".[docs]"
|
|
59
|
+
zensical serve
|
|
60
|
+
```
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "zendoc"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A family of Python-Markdown extensions for section cross-references and bibliography/citation handling"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Mark Buckwell" },
|
|
13
|
+
]
|
|
14
|
+
requires-python = ">=3.10"
|
|
15
|
+
dependencies = [
|
|
16
|
+
"Markdown>=3.4",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 3 - Alpha",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Topic :: Text Processing :: Markup :: Markdown",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/buckwem/zendoc-extension"
|
|
28
|
+
Issues = "https://github.com/buckwem/zendoc-extension/issues"
|
|
29
|
+
|
|
30
|
+
[project.entry-points."markdown.extensions"]
|
|
31
|
+
"zendoc.headings" = "zendoc.headings:HeadingsExtension"
|
|
32
|
+
"zendoc.refs" = "zendoc.refs:RefsExtension"
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
dev = [
|
|
36
|
+
"ruff",
|
|
37
|
+
"mypy",
|
|
38
|
+
"types-Markdown",
|
|
39
|
+
"pytest",
|
|
40
|
+
"pytest-cov",
|
|
41
|
+
]
|
|
42
|
+
docs = [
|
|
43
|
+
"zensical",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["src/zendoc"]
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
testpaths = ["tests"]
|
|
51
|
+
|
|
52
|
+
[tool.ruff]
|
|
53
|
+
line-length = 100
|
|
54
|
+
|
|
55
|
+
[tool.mypy]
|
|
56
|
+
python_version = "3.10"
|
|
57
|
+
strict = true
|
|
@@ -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__"]
|
|
@@ -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)
|
|
File without changes
|
|
@@ -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)
|
|
@@ -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,104 @@
|
|
|
1
|
+
# Copyright (c) 2026 Mark Buckwell and contributors
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import markdown
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from zendoc.headings import HeadingsExtension
|
|
8
|
+
from zendoc.util import DuplicateIdError, IdRegistry
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _convert(text: str, registry: IdRegistry, source: str) -> str:
|
|
12
|
+
md = markdown.Markdown(
|
|
13
|
+
extensions=["attr_list", HeadingsExtension(registry=registry, source=source)]
|
|
14
|
+
)
|
|
15
|
+
return md.convert(text)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_heading_gets_an_id_and_is_registered() -> None:
|
|
19
|
+
registry = IdRegistry()
|
|
20
|
+
html = _convert("# Introduction\n", registry, "intro.md")
|
|
21
|
+
assert 'id="introduction"' in html
|
|
22
|
+
record = registry.get("introduction")
|
|
23
|
+
assert record is not None
|
|
24
|
+
assert record.source == "intro.md"
|
|
25
|
+
assert record.level == 1
|
|
26
|
+
assert record.text == "Introduction"
|
|
27
|
+
assert record.number == "1"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_nested_headings_get_hierarchical_numbers() -> None:
|
|
31
|
+
registry = IdRegistry()
|
|
32
|
+
_convert("# Chapter\n\n## Setup\n\n## Usage\n\n# Next Chapter\n", registry, "doc.md")
|
|
33
|
+
assert registry.get("chapter").number == "1" # type: ignore[union-attr]
|
|
34
|
+
assert registry.get("setup").number == "1.1" # type: ignore[union-attr]
|
|
35
|
+
assert registry.get("usage").number == "1.2" # type: ignore[union-attr]
|
|
36
|
+
assert registry.get("next-chapter").number == "2" # type: ignore[union-attr]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_unnumbered_heading_has_no_number_but_gets_an_id() -> None:
|
|
40
|
+
registry = IdRegistry()
|
|
41
|
+
_convert("# Cover Page {: .unnumbered }\n\n# Introduction\n", registry, "doc.md")
|
|
42
|
+
assert registry.get("cover-page").number is None # type: ignore[union-attr]
|
|
43
|
+
# unnumbered heading doesn't consume a counter slot
|
|
44
|
+
assert registry.get("introduction").number == "1" # type: ignore[union-attr]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_explicit_id_is_respected() -> None:
|
|
48
|
+
registry = IdRegistry()
|
|
49
|
+
_convert("# Introduction {: #custom-id }\n", registry, "intro.md")
|
|
50
|
+
assert registry.get("custom-id") is not None
|
|
51
|
+
assert registry.get("introduction") is None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_shared_registry_across_sources() -> None:
|
|
55
|
+
registry = IdRegistry()
|
|
56
|
+
_convert("# Introduction\n", registry, "intro.md")
|
|
57
|
+
_convert("# Setup\n", registry, "setup.md")
|
|
58
|
+
assert registry.get("introduction").source == "intro.md" # type: ignore[union-attr]
|
|
59
|
+
assert registry.get("setup").source == "setup.md" # type: ignore[union-attr]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_duplicate_id_across_sources_raises() -> None:
|
|
63
|
+
registry = IdRegistry()
|
|
64
|
+
_convert("# Introduction\n", registry, "intro.md")
|
|
65
|
+
with pytest.raises(DuplicateIdError):
|
|
66
|
+
_convert("# Introduction\n", registry, "other.md")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_rebuilding_same_source_does_not_raise() -> None:
|
|
70
|
+
registry = IdRegistry()
|
|
71
|
+
_convert("# Introduction\n", registry, "intro.md")
|
|
72
|
+
_convert("# Introduction\n", registry, "intro.md")
|
|
73
|
+
assert registry.get("introduction").source == "intro.md" # type: ignore[union-attr]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_stale_heading_cleared_on_rebuild() -> None:
|
|
77
|
+
registry = IdRegistry()
|
|
78
|
+
_convert("# Old Title\n", registry, "intro.md")
|
|
79
|
+
_convert("# New Title\n", registry, "intro.md")
|
|
80
|
+
assert registry.get("old-title") is None
|
|
81
|
+
new_title = registry.get("new-title")
|
|
82
|
+
assert new_title is not None
|
|
83
|
+
assert new_title.source == "intro.md"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_reuses_callers_own_toc_config() -> None:
|
|
87
|
+
registry = IdRegistry()
|
|
88
|
+
md = markdown.Markdown(
|
|
89
|
+
extensions=[
|
|
90
|
+
"toc",
|
|
91
|
+
HeadingsExtension(registry=registry, source="intro.md"),
|
|
92
|
+
],
|
|
93
|
+
extension_configs={"toc": {"permalink": True}},
|
|
94
|
+
)
|
|
95
|
+
html = md.convert("# Introduction\n")
|
|
96
|
+
assert "headerlink" in html
|
|
97
|
+
assert registry.get("introduction") is not None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_entry_point_name_resolves() -> None:
|
|
101
|
+
md = markdown.Markdown(extensions=["zendoc.headings"])
|
|
102
|
+
assert md.convert("# Introduction\n") == (
|
|
103
|
+
'<h1 id="introduction">Introduction</h1>'
|
|
104
|
+
)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Copyright (c) 2026 Mark Buckwell and contributors
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import markdown
|
|
5
|
+
|
|
6
|
+
from zendoc.headings import HeadingsExtension
|
|
7
|
+
from zendoc.refs import RefsExtension
|
|
8
|
+
from zendoc.util import IdRegistry
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _convert(text: str, source: str = "doc.md") -> str:
|
|
12
|
+
"""Standalone use: zendoc.refs alone, auto-enabling zendoc.headings."""
|
|
13
|
+
md = markdown.Markdown(extensions=["attr_list", RefsExtension()])
|
|
14
|
+
return md.convert(text)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_ref_resolves_to_section_number() -> None:
|
|
18
|
+
html = _convert("# Introduction\n\nSee \\ref{introduction}.\n")
|
|
19
|
+
assert '<a class="zendoc-ref" href="#introduction">1</a>' in html
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_ref_resolves_nested_heading_number() -> None:
|
|
23
|
+
html = _convert(
|
|
24
|
+
"# Chapter\n\n## Setup\n\nSee \\ref{setup}.\n\n## Usage\n\nSee \\ref{usage}.\n"
|
|
25
|
+
)
|
|
26
|
+
assert '<a class="zendoc-ref" href="#setup">1.1</a>' in html
|
|
27
|
+
assert '<a class="zendoc-ref" href="#usage">1.2</a>' in html
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_forward_reference_within_same_document_resolves() -> None:
|
|
31
|
+
html = _convert("See \\ref{introduction} below.\n\n# Introduction\n")
|
|
32
|
+
assert '<a class="zendoc-ref" href="#introduction">1</a>' in html
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_unknown_id_is_unresolved() -> None:
|
|
36
|
+
html = _convert("See \\ref{does-not-exist}.\n")
|
|
37
|
+
assert '<a class="zendoc-ref zendoc-ref-unresolved">??</a>' in html
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_unnumbered_heading_is_unresolved_but_linkable() -> None:
|
|
41
|
+
html = _convert("# Cover Page {: .unnumbered }\n\nSee \\ref{cover-page}.\n")
|
|
42
|
+
assert '<a class="zendoc-ref zendoc-ref-unresolved" href="#cover-page">??</a>' in html
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_custom_unresolved_marker() -> None:
|
|
46
|
+
md = markdown.Markdown(extensions=[RefsExtension(unresolved="[MISSING]")])
|
|
47
|
+
html = md.convert("See \\ref{nope}.\n")
|
|
48
|
+
assert ">[MISSING]</a>" in html
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_ref_inside_code_span_is_not_resolved() -> None:
|
|
52
|
+
html = _convert("# Introduction\n\nType `\\ref{introduction}` literally.\n")
|
|
53
|
+
assert "\\ref{introduction}" in html
|
|
54
|
+
assert "zendoc-ref" not in html
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_ref_inside_fenced_code_block_is_not_resolved() -> None:
|
|
58
|
+
html = _convert("# Introduction\n\n```\n\\ref{introduction}\n```\n")
|
|
59
|
+
assert "\\ref{introduction}" in html
|
|
60
|
+
assert "zendoc-ref" not in html
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_shares_registry_with_explicitly_enabled_headings_extension() -> None:
|
|
64
|
+
"""zendoc.headings listed first, zendoc.refs second - the recommended
|
|
65
|
+
multi-page pattern: both share one registry across separate conversions."""
|
|
66
|
+
registry = IdRegistry()
|
|
67
|
+
md_page1 = markdown.Markdown(
|
|
68
|
+
extensions=[
|
|
69
|
+
HeadingsExtension(registry=registry, source="intro.md"),
|
|
70
|
+
RefsExtension(registry=registry),
|
|
71
|
+
]
|
|
72
|
+
)
|
|
73
|
+
md_page1.convert("# Introduction\n")
|
|
74
|
+
|
|
75
|
+
md_page2 = markdown.Markdown(
|
|
76
|
+
extensions=[
|
|
77
|
+
HeadingsExtension(registry=registry, source="usage.md"),
|
|
78
|
+
RefsExtension(registry=registry),
|
|
79
|
+
]
|
|
80
|
+
)
|
|
81
|
+
html = md_page2.convert("See \\ref{introduction}.\n")
|
|
82
|
+
assert '<a class="zendoc-ref" href="#introduction">1</a>' in html
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_entry_point_names_resolve_together() -> None:
|
|
86
|
+
md = markdown.Markdown(extensions=["zendoc.headings", "zendoc.refs"])
|
|
87
|
+
html = md.convert("# Introduction\n\nSee \\ref{introduction}.\n")
|
|
88
|
+
assert '<a class="zendoc-ref" href="#introduction">1</a>' in html
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
site_name = "zendoc"
|
|
3
|
+
site_description = "A family of Python-Markdown extensions for section cross-references and bibliography/citation handling."
|
|
4
|
+
site_author = "Mark Buckwell"
|
|
5
|
+
site_url = "https://buckwem.github.io/zendoc-extension/"
|
|
6
|
+
|
|
7
|
+
repo_url = "https://github.com/buckwem/zendoc-extension"
|
|
8
|
+
repo_name = "buckwem/zendoc-extension"
|
|
9
|
+
edit_uri = "edit/main/docs/"
|
|
10
|
+
|
|
11
|
+
copyright = """
|
|
12
|
+
Copyright © 2026 Mark Buckwell and contributors
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
nav = [
|
|
16
|
+
{"Home" = "index.md"},
|
|
17
|
+
{"Installation" = "installation.md"},
|
|
18
|
+
{"Extensions" = [
|
|
19
|
+
{"Headings" = "extensions/headings.md"},
|
|
20
|
+
{"Refs" = "extensions/refs.md"},
|
|
21
|
+
]},
|
|
22
|
+
{"About" = [
|
|
23
|
+
{"Release Notes" = "about/changelog.md"},
|
|
24
|
+
{"License" = "about/license.md"},
|
|
25
|
+
]},
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.theme]
|
|
29
|
+
language = "en"
|
|
30
|
+
features = [
|
|
31
|
+
"content.action.edit",
|
|
32
|
+
"content.code.copy",
|
|
33
|
+
"navigation.top",
|
|
34
|
+
"navigation.tracking",
|
|
35
|
+
"navigation.instant",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[[project.theme.palette]]
|
|
39
|
+
scheme = "default"
|
|
40
|
+
toggle.icon = "lucide/sun"
|
|
41
|
+
toggle.name = "Switch to dark mode"
|
|
42
|
+
|
|
43
|
+
[[project.theme.palette]]
|
|
44
|
+
scheme = "slate"
|
|
45
|
+
toggle.icon = "lucide/moon"
|
|
46
|
+
toggle.name = "Switch to light mode"
|
|
47
|
+
|
|
48
|
+
[[project.extra.social]]
|
|
49
|
+
icon = "fontawesome/brands/github"
|
|
50
|
+
link = "https://github.com/buckwem/zendoc-extension"
|
|
51
|
+
|
|
52
|
+
# ----------------------------------------------------------------------------
|
|
53
|
+
# Markdown extensions used to render these docs. pymdownx.snippets is used to
|
|
54
|
+
# embed LICENSE (outside docs_dir) into about/license.md.
|
|
55
|
+
# ----------------------------------------------------------------------------
|
|
56
|
+
[project.markdown_extensions.toc]
|
|
57
|
+
permalink = true
|
|
58
|
+
[project.markdown_extensions.admonition]
|
|
59
|
+
[project.markdown_extensions.attr_list]
|
|
60
|
+
[project.markdown_extensions.md_in_html]
|
|
61
|
+
[project.markdown_extensions.pymdownx.highlight]
|
|
62
|
+
anchor_linenums = true
|
|
63
|
+
[project.markdown_extensions.pymdownx.inlinehilite]
|
|
64
|
+
[project.markdown_extensions.pymdownx.snippets]
|
|
65
|
+
base_path = ["."]
|
|
66
|
+
[project.markdown_extensions.pymdownx.superfences]
|
|
67
|
+
[project.markdown_extensions.pymdownx.tabbed]
|
|
68
|
+
alternate_style = true
|